@aifabrix/builder 2.59.0 → 2.60.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 (55) hide show
  1. package/lib/agent-kit/git-identity.js +180 -0
  2. package/lib/agent-kit/setup.js +3 -0
  3. package/lib/agent-kit/start.js +45 -1
  4. package/lib/api/role-assistant-test-job.api.js +60 -0
  5. package/lib/api/work-search.api.js +13 -6
  6. package/lib/app/deploy.js +8 -1
  7. package/lib/app/show-display.js +1 -0
  8. package/lib/app/show-online.js +15 -0
  9. package/lib/build/standard-docker-build.js +4 -1
  10. package/lib/cli/setup-app.js +14 -1
  11. package/lib/cli/setup-utility.js +24 -1
  12. package/lib/commands/role-assistant.js +7 -0
  13. package/lib/core/env-platform-expand.js +93 -0
  14. package/lib/core/secrets-env-content.js +37 -3
  15. package/lib/deployment/installation/azure-infra-stage.js +3 -1
  16. package/lib/deployment/installation/infra-catalog.js +2 -5
  17. package/lib/generator/builders.js +17 -0
  18. package/lib/generator/helpers.js +23 -2
  19. package/lib/generator/index.js +10 -6
  20. package/lib/role-assistant/test-cases-search.js +3 -0
  21. package/lib/role-assistant/test-job-runner.js +125 -0
  22. package/lib/role-assistant/test-runner-search.js +44 -2
  23. package/lib/schema/application-schema.json +193 -2
  24. package/lib/schema/infra-parameter.schema.json +139 -33
  25. package/lib/schema/infra.parameter.yaml +28 -28
  26. package/lib/schema/infrastructure-schema.json +10 -35
  27. package/lib/utils/compose-generate-docker-compose.js +12 -0
  28. package/lib/utils/docker-build.js +29 -8
  29. package/lib/utils/docker-manifest-public-port.js +36 -0
  30. package/lib/utils/platform-resolution.js +226 -0
  31. package/lib/utils/resolve-docker-image-ref.js +8 -3
  32. package/package.json +2 -2
  33. package/templates/README.md +2 -1
  34. package/templates/agent-kit/agent-kit.yaml +4 -1
  35. package/templates/agent-kit/instructions/AGENTKIT.md +1 -1
  36. package/templates/agent-kit/instructions/root.AGENTS.md +2 -0
  37. package/templates/agent-kit/skills/aifabrix-connected-system/SKILL.md +33 -1
  38. package/templates/agent-kit/skills/aifabrix-connected-system/scripts/delivery-verdict.js +176 -0
  39. package/templates/agent-kit/skills/aifabrix-plan/SKILL.md +3 -0
  40. package/templates/agent-kit/skills/aifabrix-plan/references/interaction.md +10 -3
  41. package/templates/agent-kit/skills/aifabrix-prove/SKILL.md +22 -0
  42. package/templates/agent-kit/skills/aifabrix-role-assistant/SKILL.md +21 -0
  43. package/templates/agent-kit/skills/shared/feedback.md +31 -0
  44. package/templates/agent-kit/skills/shared/hosts.md +13 -3
  45. package/templates/agent-kit/skills/shared/status.md +85 -0
  46. package/templates/agent-kit/workspace/BUILDER_IMPROVEMENT_FINDINGS.md +27 -0
  47. package/templates/applications/builder-api/application.yaml +1 -1
  48. package/templates/applications/dataplane/application.yaml +30 -2
  49. package/templates/applications/dataplane/env.template +37 -4
  50. package/templates/applications/miso-controller/application.yaml +24 -1
  51. package/templates/applications/miso-controller/env.template +21 -34
  52. package/templates/marketplace/main.json +70 -222
  53. package/templates/python/Dockerfile.hbs +2 -0
  54. package/templates/typescript/Dockerfile.hbs +2 -0
  55. package/templates/typescript/docker-compose.hbs +12 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Git commit identity for onboarding and Agent Kit GitHub work.
3
+ *
4
+ * Resolution order:
5
+ * 1. Existing `git config` — never rewritten, because it is the user's own setting.
6
+ * 2. The AI Fabrix login token's claims, offered as editable defaults.
7
+ * 3. The prompt answer, written to git config.
8
+ *
9
+ * The GitHub token is deliberately not used for the address: `gh api user` returns null whenever
10
+ * the account keeps its email private, and reading `user/emails` would demand the broader `user`
11
+ * scope for a value the login token already holds.
12
+ *
13
+ * @fileoverview Resolve and persist git user.name / user.email
14
+ * @author AI Fabrix Team
15
+ * @version 1.0.0
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { execFileSync } = require('child_process');
21
+ const logger = require('../utils/logger');
22
+
23
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
24
+
25
+ /**
26
+ * @param {Function} exec - execFileSync
27
+ * @param {string} key - Config key
28
+ * @param {boolean} global - Read the global scope only
29
+ * @returns {string} Trimmed value or ''
30
+ */
31
+ function readConfig(exec, key, global) {
32
+ try {
33
+ const args = global ? ['config', '--global', '--get', key] : ['config', '--get', key];
34
+ return String(exec('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })).trim();
35
+ } catch {
36
+ return '';
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Current identity, preferring any effective value (local or global) over none.
42
+ * @param {Object} [options] - `execFileSync`
43
+ * @returns {{ name: string, email: string, complete: boolean }}
44
+ */
45
+ function readGitIdentity(options = {}) {
46
+ const exec = options.execFileSync || execFileSync;
47
+ const name = readConfig(exec, 'user.name', false);
48
+ const email = readConfig(exec, 'user.email', false);
49
+ return { name, email, complete: Boolean(name && email) };
50
+ }
51
+
52
+ function decodeJwtClaims(token) {
53
+ const raw = String(token || '');
54
+ const parts = raw.split('.');
55
+ if (parts.length < 2) return null;
56
+ try {
57
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Name and email from the AI Fabrix login token, used as prompt defaults.
65
+ * @param {Object} [options] - `config` and `controller` overrides for tests
66
+ * @returns {Promise<{ name: string, email: string }>} Empty strings when unavailable
67
+ */
68
+ async function identityFromLoginToken(options = {}) {
69
+ const config = options.config || require('../core/config');
70
+ try {
71
+ const controller = options.controller || (await config.getControllerUrl());
72
+ if (!controller) return { name: '', email: '' };
73
+ const entry = await config.getDeviceToken(controller);
74
+ const claims = decodeJwtClaims(entry && (entry.token || entry.accessToken));
75
+ if (!claims) return { name: '', email: '' };
76
+ return {
77
+ name: String(claims.name || '').trim(),
78
+ email: String(claims.email || claims.preferred_username || '').trim()
79
+ };
80
+ } catch {
81
+ return { name: '', email: '' };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {Object} [options] - `config`
87
+ * @returns {Promise<string>} adminEmail from config.yaml, or ''
88
+ */
89
+ async function emailFromConfig(options = {}) {
90
+ const config = options.config || require('../core/config');
91
+ try {
92
+ const cfg = await config.getConfig();
93
+ return String((cfg && cfg.adminEmail) || '').trim();
94
+ } catch {
95
+ return '';
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {string} value - Candidate address
101
+ * @returns {boolean|string} true, or the reason it is rejected
102
+ */
103
+ function validateEmail(value) {
104
+ return EMAIL_RE.test(String(value || '').trim()) || 'Enter an email address, for example name@example.com';
105
+ }
106
+
107
+ function validateName(value) {
108
+ return String(value || '').trim().length > 0 || 'Enter the name to record on commits';
109
+ }
110
+
111
+ async function promptIdentity(defaults, deps) {
112
+ const inquirer = deps.inquirer || require('inquirer');
113
+ return inquirer.prompt([
114
+ {
115
+ type: 'input',
116
+ name: 'name',
117
+ message: 'Name for git commits',
118
+ default: defaults.name || undefined,
119
+ validate: validateName
120
+ },
121
+ {
122
+ type: 'input',
123
+ name: 'email',
124
+ message: 'Email for git commits',
125
+ default: defaults.email || undefined,
126
+ validate: validateEmail
127
+ }
128
+ ]);
129
+ }
130
+
131
+ function writeGitIdentity(identity, options) {
132
+ const exec = options.execFileSync || execFileSync;
133
+ exec('git', ['config', '--global', 'user.name', identity.name], { stdio: 'ignore' });
134
+ exec('git', ['config', '--global', 'user.email', identity.email], { stdio: 'ignore' });
135
+ }
136
+
137
+ function missingIdentityError(defaults) {
138
+ const name = defaults.name || 'Your Name';
139
+ const email = defaults.email || 'you@example.com';
140
+ return new Error(
141
+ 'Git commit identity is not configured. Run:\n' +
142
+ ` git config --global user.name ${JSON.stringify(name)}\n` +
143
+ ` git config --global user.email ${email}`
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Ensures git has a commit identity, asking only when one is missing.
149
+ * @param {Object} [options] - `interactive`, `execFileSync`, `inquirer`, `config`
150
+ * @returns {Promise<{ name: string, email: string, source: 'git'|'prompt' }>}
151
+ */
152
+ async function ensureGitIdentity(options = {}) {
153
+ const current = readGitIdentity(options);
154
+ if (current.complete) {
155
+ return { name: current.name, email: current.email, source: 'git' };
156
+ }
157
+ const token = await identityFromLoginToken(options);
158
+ const defaults = {
159
+ name: current.name || token.name,
160
+ email: current.email || token.email || (await emailFromConfig(options))
161
+ };
162
+ if (!options.interactive) {
163
+ throw missingIdentityError(defaults);
164
+ }
165
+ logger.log('Git commit identity is not set. Defaults come from your AI Fabrix login; edit if needed.');
166
+ const answers = await promptIdentity(defaults, options);
167
+ const identity = { name: String(answers.name).trim(), email: String(answers.email).trim() };
168
+ writeGitIdentity(identity, options);
169
+ logger.log(`Git identity set to ${identity.name} <${identity.email}>`);
170
+ return { ...identity, source: 'prompt' };
171
+ }
172
+
173
+ module.exports = {
174
+ readGitIdentity,
175
+ identityFromLoginToken,
176
+ emailFromConfig,
177
+ ensureGitIdentity,
178
+ validateEmail,
179
+ validateName
180
+ };
@@ -8,6 +8,7 @@ const { auditLog } = require('../core/audit-logger');
8
8
  const { normalizeSetup, preflight, resolveWork } = require('./setup-context');
9
9
  const { acquireWorkspace } = require('./setup-state');
10
10
  const { ensureController, ensureGithubLogin, ensureProvider } = require('./setup-auth');
11
+ const { ensureGitIdentity } = require('./git-identity');
11
12
  const { prepareWorkspace, validateExisting } = require('./setup-workspace');
12
13
  const { prepareSession, attachSession } = require('./setup-session');
13
14
  const { managedSetupOptions } = require('./setup-managed');
@@ -19,6 +20,8 @@ async function authenticate(context, options, setPhase) {
19
20
  if (context.fullName) {
20
21
  setPhase('github-login');
21
22
  ensureGithubLogin(options);
23
+ // Commits need an author. The login token supplies the default; the user can change it.
24
+ await ensureGitIdentity({ ...options, interactive: options.interactive !== false });
22
25
  }
23
26
  if (context.code === 'cursor') return null;
24
27
  setPhase('agent-login');
@@ -116,6 +116,27 @@ function claudeInnerArgs(p) {
116
116
  ]);
117
117
  }
118
118
 
119
+ /**
120
+ * POSIX single-quote escaping for a value sent to a shell via tmux send-keys.
121
+ * @param {string} value - Raw value
122
+ * @returns {string} Quoted value
123
+ */
124
+ function shellQuote(value) {
125
+ const escaped = String(value).split('\'').join('\'\\\'\'');
126
+ return `'${escaped}'`;
127
+ }
128
+
129
+ /**
130
+ * Codex authenticates inside the session, not in the window that launched it. Running it as the
131
+ * tmux session command makes the session end with the sign-in process, so the shell owns the
132
+ * window and codex is started inside it with send-keys.
133
+ * @param {object} p - Start parameters
134
+ * @returns {string} Shell command to run inside the session
135
+ */
136
+ function codexStartCommand(p) {
137
+ return p.binary ? `exec ${shellQuote(p.binary)}` : 'exec codex';
138
+ }
139
+
119
140
  /**
120
141
  * @param {object} p
121
142
  * @returns {object}
@@ -127,12 +148,17 @@ function buildTmuxSpec(p) {
127
148
  ? ['new-session', '-d', '-s', p.session, '-c', p.cwd]
128
149
  : ['new-session', '-s', p.session, '-c', p.cwd];
129
150
  const remote = p.app === 'claude' && p.remoteControl;
151
+ // codex: shell owns the window, codex starts inside it (see codexStartCommand).
152
+ const launchArgs = p.app === 'codex'
153
+ ? [...mode]
154
+ : [...mode, ...(p.binary ? ['env', p.binary] : [spec.bin]), ...extra];
130
155
  return {
131
156
  bin: 'tmux',
132
- args: [...mode, ...(p.binary ? ['env', p.binary] : [spec.bin]), ...extra],
157
+ args: launchArgs,
133
158
  session: p.session,
134
159
  appBin: spec.bin,
135
160
  needTmux: true,
161
+ innerCommand: p.app === 'codex' ? codexStartCommand(p) : undefined,
136
162
  remoteControlName: remote ? p.remoteControlName : undefined,
137
163
  remoteControlPrefix: remote ? p.remoteControlPrefix : undefined
138
164
  };
@@ -333,6 +359,22 @@ function executeStart(options, context, execute) {
333
359
  startCodexRemote(options, context.remoteSpec);
334
360
  }
335
361
  spawnLaunch(options, context.spec);
362
+ sendInnerCommand(options, context.spec);
363
+ }
364
+
365
+ /**
366
+ * Starts the agent inside an existing session window. Only codex uses this; the session must exist
367
+ * first, which is why it runs after the launch.
368
+ * @param {object} options - Start options
369
+ * @param {object} spec - Launch spec
370
+ * @returns {void}
371
+ */
372
+ function sendInnerCommand(options, spec) {
373
+ if (!spec.innerCommand) {
374
+ return;
375
+ }
376
+ const exec = options.execFileSync || execFileSync;
377
+ exec('tmux', ['send-keys', '-t', `=${spec.session}`, spec.innerCommand, 'Enter'], { stdio: 'inherit' });
336
378
  }
337
379
 
338
380
  /**
@@ -363,6 +405,8 @@ function runStart(options) {
363
405
 
364
406
  module.exports = {
365
407
  START_APPS,
408
+ codexStartCommand,
409
+ sendInnerCommand,
366
410
  normalizeStartApp,
367
411
  sanitizeSessionName,
368
412
  resolveSessionName,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Dataplane asynchronous Role Assistant test job API.
3
+ *
4
+ * @fileoverview Enqueue and poll published Role Assistant cases
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { createDataplaneApiClient } = require('./index');
10
+ const { throwIfApiFailed } = require('./api-response-errors');
11
+ const { unwrapApiData } = require('../utils/external-system-readiness-core');
12
+
13
+ function jobPath(systemIdOrKey, suffix = '') {
14
+ const key = encodeURIComponent(String(systemIdOrKey || '').trim());
15
+ return `/api/v1/external/systems/${key}/role-assistant-tests/jobs${suffix}`;
16
+ }
17
+
18
+ function unwrapJob(res) {
19
+ const value = unwrapApiData(res);
20
+ if (value && typeof value === 'object' && !Array.isArray(value) && value.data) {
21
+ return value.data;
22
+ }
23
+ return value;
24
+ }
25
+
26
+ async function enqueueRoleAssistantTestJob(
27
+ dataplaneUrl,
28
+ authConfig,
29
+ systemIdOrKey,
30
+ body
31
+ ) {
32
+ const client = createDataplaneApiClient(dataplaneUrl, authConfig);
33
+ const path = jobPath(systemIdOrKey);
34
+ const res = await client.post(path, { body, timeoutMs: 60000 });
35
+ throwIfApiFailed(res, {
36
+ endpointUrl: `${String(dataplaneUrl || '').replace(/\/$/, '')}${path}`
37
+ });
38
+ return unwrapJob(res);
39
+ }
40
+
41
+ async function getRoleAssistantTestJob(
42
+ dataplaneUrl,
43
+ authConfig,
44
+ systemIdOrKey,
45
+ jobId
46
+ ) {
47
+ const suffix = `/${encodeURIComponent(String(jobId || '').trim())}`;
48
+ const client = createDataplaneApiClient(dataplaneUrl, authConfig);
49
+ const path = jobPath(systemIdOrKey, suffix);
50
+ const res = await client.get(path, { timeoutMs: 60000 });
51
+ throwIfApiFailed(res, {
52
+ endpointUrl: `${String(dataplaneUrl || '').replace(/\/$/, '')}${path}`
53
+ });
54
+ return unwrapJob(res);
55
+ }
56
+
57
+ module.exports = {
58
+ enqueueRoleAssistantTestJob,
59
+ getRoleAssistantTestJob
60
+ };
@@ -121,16 +121,23 @@ async function getWorkRoles(dataplaneUrl, authConfig) {
121
121
 
122
122
  /**
123
123
  * GET-equivalent work.skills for one roleRef.
124
- * Requests detail=full so search.resourceTypes (executable grammar) is present.
125
- * Compact summary omits that slice and cannot evaluate named viewpoint ops.
124
+ * Defaults to detail=full so search.resourceTypes (executable grammar) is present.
125
+ * A caller may request progressive resource/operation detail for skills-only checks.
126
126
  * @requiresPermission {Dataplane} Authenticated (oauth2: [])
127
127
  * @async
128
128
  */
129
- async function getWorkSkills(dataplaneUrl, authConfig, roleRef) {
130
- return executeLayerBTool(dataplaneUrl, authConfig, LAYER_B_TOOLS.skills, {
129
+ async function getWorkSkills(dataplaneUrl, authConfig, roleRef, options = {}) {
130
+ const args = {
131
131
  roleRef: String(roleRef || '').trim(),
132
- detail: 'full'
133
- });
132
+ detail: String(options.detail || 'full').trim()
133
+ };
134
+ if (typeof options.resourceType === 'string' && options.resourceType.trim()) {
135
+ args.resourceType = options.resourceType.trim();
136
+ }
137
+ if (typeof options.operation === 'string' && options.operation.trim()) {
138
+ args.operation = options.operation.trim();
139
+ }
140
+ return executeLayerBTool(dataplaneUrl, authConfig, LAYER_B_TOOLS.skills, args);
134
141
  }
135
142
 
136
143
  /**
package/lib/app/deploy.js CHANGED
@@ -268,7 +268,14 @@ async function executeStandardDeployment(appName, options) {
268
268
  const controllerUrl = config.controllerUrl || 'unknown';
269
269
  const appExists = await checkApplicationExists(appName, controllerUrl, config.envKey, config.auth);
270
270
 
271
- const { manifest, manifestPath } = await generateAndValidateManifest(appName, options);
271
+ // A controller deployment ships tokens plus the platforms/environments sections, so the
272
+ // Controller resolves them per installation and can promote dev -> tst without regenerating.
273
+ // Local runs and an explicit --resource-group resolve here instead.
274
+ const resolveHere = Boolean(options.local || options.resourceGroup);
275
+ const { manifest, manifestPath } = await generateAndValidateManifest(appName, {
276
+ ...options,
277
+ ...(resolveHere ? { target: { environment: config.envKey, resourceGroup: options.resourceGroup } } : {})
278
+ });
272
279
  applyManifestOverrides(manifest, options);
273
280
  validateImageIsPullable(manifest.image, appName);
274
281
  displayDeploymentInfo(manifest, manifestPath);
@@ -234,6 +234,7 @@ function logExternalSystemMain(ext) {
234
234
  logger.log(` Version: ${ext.version ?? '—'}`);
235
235
  logger.log(` Credential: ${formatCredentialShowLine(ext)}`);
236
236
  logger.log(` Status: ${formatDataplaneStatusDisplay(ext.status)}`);
237
+ logger.log(` System link: ${ext.systemUrl ?? '—'}`);
237
238
  logger.log(` API docs: ${ext.openApiDocsPageUrl ?? ext.apiDocumentUrl ?? '—'}`);
238
239
  logger.log(` MCP server: ${ext.mcpServerUrl ?? '—'}`);
239
240
  logger.log(` OpenAPI spec: ${ext.apiDocumentUrl ?? '—'}`);
@@ -89,6 +89,7 @@ function buildOnlineSummaryFromDataplane(appKey, controllerUrl, dataplaneUrl, ex
89
89
 
90
90
  async function finishOnlineShow(summary, appKey, json, permissionsOnly, verifyCert, authBundle) {
91
91
  await enrichExternalShowSummary(summary, appKey, verifyCert, authBundle);
92
+ attachSystemUrl(summary);
92
93
  attachRunDefaultsFromUserConfig(summary, appKey, await getConfig());
93
94
  await attachDeclarativeUrlsToShowApplication(summary, appKey);
94
95
  if (json) {
@@ -100,6 +101,20 @@ async function finishOnlineShow(summary, appKey, json, permissionsOnly, verifyCe
100
101
  displayShow(summary, { permissionsOnly: !!permissionsOnly });
101
102
  }
102
103
 
104
+ /**
105
+ * Expose the Dataplane system browser route for humans and agents.
106
+ * Preserve the deployment base path (for example /data).
107
+ * @param {Object} summary - Online application summary
108
+ */
109
+ function attachSystemUrl(summary) {
110
+ const ext = summary.externalSystem;
111
+ if (!summary.isExternal || !ext || ext.error || !ext.dataplaneUrl) return;
112
+ const baseUrl = ext.dataplaneUrl.replace(/\/+$/, '');
113
+ const systemKey = ext.systemKey || summary.appKey;
114
+ const section = /^(ra-|ek-)/.test(systemKey) ? 'enterprise-knowledge' : 'connected-systems';
115
+ ext.systemUrl = `${baseUrl}/${section}/${encodeURIComponent(systemKey)}`;
116
+ }
117
+
103
118
  async function runOnlineFromDataplane(appKey, json, permissionsOnly, verifyCert, authConfig, authResult) {
104
119
  const environment = await resolveEnvironment();
105
120
  const dataplaneUrl = await resolveDataplaneUrl(authResult.actualControllerUrl, environment, authConfig);
@@ -81,13 +81,16 @@ async function executeDockerImageBuild(appName, options, buildConfig) {
81
81
  await ensureRunSecretsForApp(appName);
82
82
  const buildArgs = await resolveDockerBuildArgsForApp(appName, contextPath);
83
83
  const { primary, aliases, tags } = options.tagPlan;
84
+ // `build.target` selects a stage of a multi-stage Dockerfile, so one source tree can
85
+ // produce more than one image. Absent, Docker builds the last stage as before.
86
+ const target = buildConfig && buildConfig.target;
84
87
  await dockerBuild.executeDockerBuildWithTag(
85
88
  effectiveImageName,
86
89
  imageName,
87
90
  dockerfilePath,
88
91
  contextPath,
89
92
  primary,
90
- { ...options, buildArgs, tagAliases: aliases }
93
+ { ...options, buildArgs, tagAliases: aliases, target }
91
94
  );
92
95
  await postBuildTasks(appName, buildConfig);
93
96
  logger.log(formatSuccessParagraph('Build completed successfully!'));
@@ -362,7 +362,7 @@ function setupDockerfileGenerateCommand(program) {
362
362
  }
363
363
 
364
364
  function setupDeployCommand(program) {
365
- program.command('deploy <appKey|systemKey>')
365
+ addDeployTargetOptions(program.command('deploy <appKey|systemKey>'))
366
366
  .description('Deploy builder app or external system via Miso Controller (Azure or --local)')
367
367
  .addHelpText('after', DEPLOY_HELP_AFTER)
368
368
  .option('--local', 'Send manifest to controller then run app locally (app: same as aifabrix run <app>; external: restart dataplane)')
@@ -410,6 +410,19 @@ function setupDeployCommand(program) {
410
410
  });
411
411
  }
412
412
 
413
+ /**
414
+ * Deployment-target options. A controller deployment normally ships {NAME} tokens plus the
415
+ * platforms/environments sections; these flags force local resolution instead.
416
+ * @param {Command} command - Commander command
417
+ * @returns {Command} The command
418
+ */
419
+ function addDeployTargetOptions(command) {
420
+ return command.option(
421
+ '-g, --resource-group <name>',
422
+ 'Target Azure resource group; resolves platforms values in the generated manifest instead of shipping tokens'
423
+ );
424
+ }
425
+
413
426
  function setupPushDeployDockerfileCommands(program) {
414
427
  program.command('push <appKey>')
415
428
  .description('Push builder appKey image to Azure Container Registry')
@@ -65,6 +65,23 @@ async function handleSplitJsonCommand(appName, options) {
65
65
  return generator.splitDeployJson(deployJsonPath, outputDir);
66
66
  }
67
67
 
68
+ /**
69
+ * Deployment target for manifest generation. A resource group resolves platform values; the
70
+ * environment key resolves the environments layer. Without either, {NAME} tokens are preserved.
71
+ * @param {Object} options - Command options
72
+ * @returns {Promise<{ resourceGroup: string|undefined, environment: string }>}
73
+ */
74
+ async function buildGenerationTarget(options = {}) {
75
+ // Only an explicit target resolves. The default artifact keeps {NAME} tokens and carries the
76
+ // platforms/environments sections, so the Controller resolves them per installation.
77
+ if (!options.resourceGroup && !options.env) return undefined;
78
+ const { resolveEnvironment } = require('../core/config');
79
+ const environment = options.env
80
+ ? String(options.env).trim().toLowerCase()
81
+ : await resolveEnvironment();
82
+ return { resourceGroup: options.resourceGroup, environment };
83
+ }
84
+
68
85
  /**
69
86
  * Logs split-json results
70
87
  * @param {Object} result - Generated file paths
@@ -93,6 +110,11 @@ function logSplitJsonResult(result) {
93
110
  function setupJsonCommand(program) {
94
111
  program.command('json <appKey|systemKey>')
95
112
  .description('Write deployment JSON to disk for version control')
113
+ .option(
114
+ '-g, --resource-group <name>',
115
+ 'Target Azure resource group; resolves platforms/environments values instead of leaving {NAME} tokens'
116
+ )
117
+ .option('-e, --env <env>', 'Deployment environment key for environments resolution (default: current environment)')
96
118
  .addHelpText('after', JSON_HELP_AFTER)
97
119
  .action(async(appName, options) => {
98
120
  try {
@@ -105,7 +127,8 @@ function setupJsonCommand(program) {
105
127
  json: false
106
128
  });
107
129
  const result = await generator.generateDeployJsonWithValidation(appName, {
108
- ...options
130
+ ...options,
131
+ target: await buildGenerationTarget(options)
109
132
  });
110
133
  if (result.success) {
111
134
  const fileName = result.path.includes('application-schema.json') ? 'application-schema.json' : 'deployment JSON';
@@ -41,6 +41,10 @@ const {
41
41
  patchCaseStatusAfterRun
42
42
  } = require('../role-assistant/test-pack-sync');
43
43
  const { collectString } = require('./governance');
44
+ const {
45
+ hasRoleAssistantTestSubject,
46
+ runPublishedCaseAsSubject
47
+ } = require('../role-assistant/test-job-runner');
44
48
  const {
45
49
  sectionTitle,
46
50
  headerKeyValue,
@@ -338,6 +342,9 @@ async function resolveExecutableCaseResult(roleKey, roleCase, opts, result, verd
338
342
  run: false,
339
343
  noPublish: true
340
344
  });
345
+ if (hasRoleAssistantTestSubject(opts)) {
346
+ return runPublishedCaseAsSubject(roleKey, roleCase, opts, authContext);
347
+ }
341
348
  const evidencePreflight = await assertRoleAssistantEvidencePreflight(roleCase, authContext);
342
349
  const executionResult = await runRoleAssistantCaseLifecycle(roleKey, roleCase, authContext, opts);
343
350
  const executionVerdict = lifecycleVerdictFromCaseExecution(executionResult, verdict);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `{NAME}` expansion in env content (plan 200.0).
3
+ *
4
+ * Values resolved from the manifest `platforms` / `environments` sections are substituted into
5
+ * `env.template` values. Runs after `kv://` and before `url://`, so a resolved value may itself
6
+ * carry a declarative URL.
7
+ *
8
+ * Only the value side of `KEY=value` is touched, and only `{UPPER_SNAKE}` tokens that are not
9
+ * preceded by `$`. That leaves Builder's existing `${VAR}` expansion and `{lowercase}` text in
10
+ * comments alone — both appear in shipped templates.
11
+ *
12
+ * @fileoverview Platform/environment name expansion for env.template
13
+ * @author AI Fabrix Team
14
+ * @version 1.0.0
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { resolveConfiguration } = require('../utils/platform-resolution');
20
+
21
+ /** `{NAME}` not preceded by `$`; uppercase only, matching the configuration name pattern. */
22
+ const NAME_TOKEN = /(?<!\$)\{([A-Z_][A-Z0-9_]*)\}/g;
23
+
24
+ /**
25
+ * @param {Object} [manifest] - Application manifest
26
+ * @returns {boolean} True when the manifest declares either section
27
+ */
28
+ function declaresPlatformSections(manifest) {
29
+ if (!manifest || typeof manifest !== 'object') return false;
30
+ return Array.isArray(manifest.platforms) || Array.isArray(manifest.environments);
31
+ }
32
+
33
+ function expandValue(value, values, key, strict, context = {}) {
34
+ return value.replace(NAME_TOKEN, (match, name) => {
35
+ if (Object.prototype.hasOwnProperty.call(values, name)) return values[name];
36
+ if (!strict) return match;
37
+ // Without a resource group no platform matched, so only environments and built-ins resolved.
38
+ // Saying so turns "why is this missing" into "declare it here".
39
+ const where = context.resourceGroup
40
+ ? `resource group "${context.resourceGroup}" and environment "${context.environment || ''}"`
41
+ : `environment "${context.environment || ''}" (no resource group, so platform values are unavailable)`;
42
+ throw new Error(
43
+ `${key} references {${name}}, which no platform or environment configuration resolves for ` +
44
+ `${where}. Declare it under environments, or run against a resource group that supplies it.`
45
+ );
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Expands `{NAME}` on the value side of every assignment line.
51
+ * @param {string} content - Env content
52
+ * @param {Object} values - Resolved names
53
+ * @param {Object} [opts] - `strict` (default true) errors on an unresolved name; false leaves the
54
+ * token in place for a later resolver (the deploy manifest, resolved by Miso per installation)
55
+ * @returns {string}
56
+ * @throws {Error} When strict and a referenced name is not resolved
57
+ */
58
+ function expandNamesInEnvContent(content, values, opts = {}) {
59
+ const strict = opts.strict !== false;
60
+ const context = { environment: opts.environment, resourceGroup: opts.resourceGroup };
61
+ const lines = String(content || '').split('\n');
62
+ const out = lines.map(line => {
63
+ const trimmed = line.trimStart();
64
+ if (!trimmed || trimmed.startsWith('#')) return line;
65
+ const separator = line.indexOf('=');
66
+ if (separator < 0) return line;
67
+ const key = line.slice(0, separator);
68
+ const value = line.slice(separator + 1);
69
+ return `${key}=${expandValue(value, values, key.trim(), strict, context)}`;
70
+ });
71
+ return out.join('\n');
72
+ }
73
+
74
+ /**
75
+ * Resolves the manifest sections and expands `{NAME}` when either section is declared.
76
+ * A manifest without them is returned untouched, so existing applications are unaffected.
77
+ * @param {string} content - Env content after kv:// resolution
78
+ * @param {Object} params - `manifest`, `environment` (deployment env key), `resourceGroup`, `strict`
79
+ * @returns {string}
80
+ */
81
+ function expandPlatformNamesIfPresent(content, params = {}) {
82
+ const { manifest, environment, resourceGroup, strict } = params;
83
+ if (!declaresPlatformSections(manifest)) return content;
84
+ const { values } = resolveConfiguration(manifest, { environment, resourceGroup });
85
+ return expandNamesInEnvContent(content, values, { strict, environment, resourceGroup });
86
+ }
87
+
88
+ module.exports = {
89
+ NAME_TOKEN,
90
+ declaresPlatformSections,
91
+ expandNamesInEnvContent,
92
+ expandPlatformNamesIfPresent
93
+ };