@formigio/fazemos-cli 0.10.35 → 0.10.37

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.
package/dist/index.js CHANGED
@@ -110,6 +110,32 @@ function parseJson(val, flag) {
110
110
  process.exit(1);
111
111
  }
112
112
  }
113
+ /**
114
+ * Parse JSON from an inline string or `@filepath`. Empty string returns null (clears the field).
115
+ * Used by --verification and --uat flags.
116
+ */
117
+ function parseJsonOrFile(val, flag) {
118
+ if (val === '')
119
+ return null;
120
+ let raw = val;
121
+ if (val.startsWith('@')) {
122
+ const filePath = resolve(val.slice(1));
123
+ try {
124
+ raw = readFileSync(filePath, 'utf-8');
125
+ }
126
+ catch (err) {
127
+ console.error(chalk.red(`Cannot read ${flag} file "${filePath}": ${err.message}`));
128
+ process.exit(1);
129
+ }
130
+ }
131
+ try {
132
+ return JSON.parse(raw);
133
+ }
134
+ catch {
135
+ console.error(chalk.red(`Invalid JSON for ${flag}. Check quoting and syntax.`));
136
+ process.exit(1);
137
+ }
138
+ }
113
139
  /** Resolve a value that may be `@filepath` (reads file contents) or inline text. */
114
140
  function resolveFileOrInline(val) {
115
141
  if (val.startsWith('@')) {
@@ -1108,7 +1134,8 @@ projects
1108
1134
  });
1109
1135
  projects
1110
1136
  .command('create')
1111
- .description('Create a new project in the active organization. Owner/admin only. Slug is immutable after creation — pick carefully.')
1137
+ .description('Create a new project in the active organization. Owner/admin only. Slug is immutable after creation — pick carefully.\n\n' +
1138
+ 'Next: `fazemos projects setup <slug>` to bind docs and connection.')
1112
1139
  .argument('<slug>', 'URL slug (lowercase letters, numbers, hyphens; 1-32 chars). Used in URLs and CLI config — cannot be changed.')
1113
1140
  .requiredOption('-n, --name <name>', 'Human-readable name (max 200 chars)')
1114
1141
  .option('-d, --description <desc>', 'Optional description (max 2000 chars)')
@@ -1337,7 +1364,8 @@ projects
1337
1364
  // ── F16 — projects set-connection (binds a Connection to a project) ─
1338
1365
  projects
1339
1366
  .command('set-connection')
1340
- .description('Bind a GitHub Connection to a project. Pass "none" to unbind. Owner/admin only.')
1367
+ .description('If this is a fresh project, use `fazemos projects setup` instead that command binds docs + connection together.\n\n' +
1368
+ 'Bind a GitHub Connection to a project. Pass "none" to unbind. Owner/admin only.')
1341
1369
  .argument('<slug>', 'Project slug')
1342
1370
  .argument('<connection>', 'Connection ID, or "none" to unbind')
1343
1371
  .action(async (slug, connection) => {
@@ -1390,6 +1418,200 @@ projects
1390
1418
  process.exit(1);
1391
1419
  }
1392
1420
  });
1421
+ // ── F42 — projects setup (binds docs + connection in one call) ──────────────
1422
+ projects
1423
+ .command('setup')
1424
+ .description('Bind docs repo, docs root, and/or VCS connection to an existing project in one audited admin call. ' +
1425
+ 'Owner/admin only — agent identities are rejected. At least one flag required.')
1426
+ .argument('<slug>', 'Project slug')
1427
+ .option('--docs-repo <owner/repo>', 'Docs repository in <owner>/<repo> format (omit to leave unchanged)')
1428
+ .option('--docs-root <ROOT|path>', 'Docs root path. Pass ROOT (case-insensitive) for repo root. Omit to leave unchanged.')
1429
+ .option('--vcs-connection-id <uuid>', 'VCS connection UUID to bind (omit to leave unchanged)')
1430
+ .option('--json', 'Emit JSON instead of human-readable output')
1431
+ .action(async (slug, opts) => {
1432
+ // ── Client-side validation ──────────────────────────────────
1433
+ const hasDocsRepo = opts.docsRepo !== undefined;
1434
+ const hasDocsRoot = opts.docsRoot !== undefined;
1435
+ const hasVcsConnectionId = opts.vcsConnectionId !== undefined;
1436
+ if (!hasDocsRepo && !hasDocsRoot && !hasVcsConnectionId) {
1437
+ const msg = 'No flags passed. Provide at least one of --docs-repo, --docs-root, --vcs-connection-id.';
1438
+ if (opts.json) {
1439
+ console.log(JSON.stringify({ error: { code: 'NO_FLAGS_PASSED', message: msg } }));
1440
+ }
1441
+ else {
1442
+ console.error(chalk.red(msg));
1443
+ console.error(chalk.gray('Run: fazemos projects setup --help'));
1444
+ }
1445
+ process.exit(1);
1446
+ return; // guard: process.exit is mocked in tests
1447
+ }
1448
+ if (hasDocsRoot) {
1449
+ const rawRoot = opts.docsRoot;
1450
+ if (rawRoot.trim() === '') {
1451
+ const msg = 'docs_root_empty: --docs-root cannot be an empty string or whitespace. Pass ROOT for repo root or a valid subpath.';
1452
+ if (opts.json) {
1453
+ console.log(JSON.stringify({ error: { code: 'docs_root_empty', message: msg } }));
1454
+ }
1455
+ else {
1456
+ console.error(chalk.red(msg));
1457
+ console.error(chalk.gray('See: docs/architecture/repo-root-vs-subdir-docs.md'));
1458
+ }
1459
+ process.exit(1);
1460
+ return; // guard: process.exit is mocked in tests
1461
+ }
1462
+ if (rawRoot.length > 500) {
1463
+ const msg = `docs_root_too_long: --docs-root exceeds 500 characters (got ${rawRoot.length}).`;
1464
+ if (opts.json) {
1465
+ console.log(JSON.stringify({ error: { code: 'docs_root_too_long', message: msg } }));
1466
+ }
1467
+ else {
1468
+ console.error(chalk.red(msg));
1469
+ }
1470
+ process.exit(1);
1471
+ return; // guard: process.exit is mocked in tests
1472
+ }
1473
+ }
1474
+ if (hasDocsRepo && opts.docsRepo !== null) {
1475
+ const repoPattern = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1476
+ if (!repoPattern.test(opts.docsRepo)) {
1477
+ const msg = `docs_repo_shape: --docs-repo must be in <owner>/<repo> format (got: ${opts.docsRepo}).`;
1478
+ if (opts.json) {
1479
+ console.log(JSON.stringify({ error: { code: 'docs_repo_shape', message: msg } }));
1480
+ }
1481
+ else {
1482
+ console.error(chalk.red(msg));
1483
+ }
1484
+ process.exit(1);
1485
+ return; // guard: process.exit is mocked in tests
1486
+ }
1487
+ }
1488
+ // ── Resolve slug → project ID ──────────────────────────────
1489
+ const orgId = requireActiveOrgOrExit();
1490
+ let project = findProjectBySlug(orgId, slug);
1491
+ if (!project) {
1492
+ await refreshAuthMeCache();
1493
+ project = findProjectBySlug(orgId, slug);
1494
+ }
1495
+ if (!project) {
1496
+ const msg = `Unknown project: ${slug}`;
1497
+ if (opts.json) {
1498
+ console.log(JSON.stringify({ error: { code: 'PROJECT_NOT_FOUND', message: msg } }));
1499
+ }
1500
+ else {
1501
+ console.error(chalk.red(msg));
1502
+ console.log(chalk.gray('Run: fazemos projects list'));
1503
+ }
1504
+ process.exit(1);
1505
+ return; // guard: process.exit is mocked in tests
1506
+ }
1507
+ // ── Build request body ─────────────────────────────────────
1508
+ // OQ-3: ROOT (case-insensitive) → null (means "repo root" at API)
1509
+ const body = {};
1510
+ if (hasDocsRepo)
1511
+ body.docsRepo = opts.docsRepo ?? null;
1512
+ if (hasDocsRoot) {
1513
+ body.docsRoot = opts.docsRoot.toUpperCase() === 'ROOT' ? null : opts.docsRoot;
1514
+ }
1515
+ if (hasVcsConnectionId)
1516
+ body.vcsConnectionId = opts.vcsConnectionId ?? null;
1517
+ // ── API call ───────────────────────────────────────────────
1518
+ try {
1519
+ const data = await api('POST', `/api/admin/projects/${project.id}/setup`, body, { noProjectHeader: true });
1520
+ if (opts.json) {
1521
+ console.log(JSON.stringify({
1522
+ status: 'ok',
1523
+ project: data.project,
1524
+ auditLogId: data.auditLogId,
1525
+ changes: data.changes,
1526
+ }));
1527
+ return;
1528
+ }
1529
+ // ── Human-readable success output (Sage UX §"Success output") ──
1530
+ const p = data.project;
1531
+ const changes = data.changes ?? {};
1532
+ const changedFields = Object.keys(changes);
1533
+ const passedFields = Object.keys(body);
1534
+ const unchangedFields = passedFields.filter(f => !changedFields.includes(f));
1535
+ console.log(chalk.green(`✓ ${p.name} (${p.slug}) — onboarded`));
1536
+ if (changedFields.includes('docsRepo') || passedFields.includes('docsRepo')) {
1537
+ const val = p.docsRepo ?? '(cleared)';
1538
+ const marker = changedFields.includes('docsRepo') ? '' : chalk.gray(' (unchanged)');
1539
+ console.log(` docs_repo: ${val}${marker}`);
1540
+ }
1541
+ if (changedFields.includes('docsRoot') || passedFields.includes('docsRoot')) {
1542
+ const val = p.docsRoot === null ? 'ROOT (repo root — no subdirectory)' : p.docsRoot;
1543
+ const marker = changedFields.includes('docsRoot') ? '' : chalk.gray(' (unchanged)');
1544
+ console.log(` docs_root: ${val}${marker}`);
1545
+ }
1546
+ if (changedFields.includes('vcsConnectionId') || passedFields.includes('vcsConnectionId')) {
1547
+ const val = p.vcsConnectionId ?? '(cleared)';
1548
+ const marker = changedFields.includes('vcsConnectionId') ? '' : chalk.gray(' (unchanged)');
1549
+ console.log(` vcs_connection_id: ${val}${marker}`);
1550
+ }
1551
+ if (unchangedFields.length > 0) {
1552
+ console.log(chalk.gray(` (unchanged: ${unchangedFields.join(', ')})`));
1553
+ }
1554
+ console.log(` Audit: logged as project_setup (id: ${data.auditLogId})`);
1555
+ }
1556
+ catch (err) {
1557
+ if (opts.json) {
1558
+ console.log(JSON.stringify({ error: { code: err.code ?? 'ERROR', message: err.message } }));
1559
+ process.exit(1);
1560
+ }
1561
+ if (err instanceof ApiError) {
1562
+ switch (err.code) {
1563
+ case 'PROJECT_NOT_FOUND':
1564
+ console.error(chalk.red(`Project not found: ${slug}`));
1565
+ console.log(chalk.gray('Run: fazemos projects list'));
1566
+ break;
1567
+ case 'FORBIDDEN_ROLE':
1568
+ console.error(chalk.red('Access denied. Owner or admin role required. Agent identities are not permitted.'));
1569
+ break;
1570
+ case 'AT_LEAST_ONE_FIELD_REQUIRED':
1571
+ console.error(chalk.red('At least one of --docs-repo, --docs-root, --vcs-connection-id must be provided.'));
1572
+ break;
1573
+ case 'VALIDATION_ERROR': {
1574
+ const body2 = (err.body ?? {});
1575
+ const sub = body2.subCode ?? '';
1576
+ if (sub === 'docs_root_empty') {
1577
+ console.error(chalk.red('docs_root_empty: --docs-root cannot be empty or whitespace. Pass ROOT for repo root.'));
1578
+ console.error(chalk.gray('See: docs/architecture/repo-root-vs-subdir-docs.md'));
1579
+ }
1580
+ else if (sub === 'docs_root_too_long') {
1581
+ console.error(chalk.red('docs_root_too_long: --docs-root exceeds 500 characters.'));
1582
+ }
1583
+ else if (sub === 'docs_repo_shape') {
1584
+ console.error(chalk.red('docs_repo_shape: --docs-repo must be in <owner>/<repo> format.'));
1585
+ }
1586
+ else if (sub === 'vcs_connection_uuid') {
1587
+ console.error(chalk.red('vcs_connection_uuid: --vcs-connection-id must be a valid UUID.'));
1588
+ }
1589
+ else {
1590
+ console.error(chalk.red(`Validation error: ${err.message}`));
1591
+ }
1592
+ break;
1593
+ }
1594
+ case 'CONNECTION_NOT_FOUND':
1595
+ console.error(chalk.red('VCS connection not found.'));
1596
+ console.log(chalk.gray('List your connections: fazemos connections list'));
1597
+ break;
1598
+ case 'CONNECTION_CROSS_ORG':
1599
+ console.error(chalk.red('That VCS connection belongs to a different organization.'));
1600
+ break;
1601
+ case 'CONNECTION_NOT_ACTIVE':
1602
+ console.error(chalk.red('VCS connection is not active and cannot be bound.'));
1603
+ console.log(chalk.gray('Pick an active connection: fazemos connections list'));
1604
+ break;
1605
+ default:
1606
+ console.error(chalk.red(err.message));
1607
+ }
1608
+ }
1609
+ else {
1610
+ console.error(chalk.red(err.message));
1611
+ }
1612
+ process.exit(1);
1613
+ }
1614
+ });
1393
1615
  // ── F16 — Connections ───────────────────────────────────────
1394
1616
  const connections = program.command('connections').alias('conn').description('GitHub Connection commands');
1395
1617
  /**
@@ -3039,10 +3261,17 @@ templates
3039
3261
  .command('show')
3040
3262
  .description('Show template detail including phases, steps, I/O declarations, pipeline inputs, and revision number. Use this to inspect the full structure of a template and discover phase/step IDs needed by other commands.')
3041
3263
  .argument('<id>', 'Template ID (use "tpl list" to find IDs)')
3042
- .action(async (id) => {
3264
+ .option('--json', 'Output the template definition as JSON (same as tpl export). Useful for piping/inspecting without a temp file.')
3265
+ .option('--show-sections', 'Print full sections content for all steps (default: truncated at 200 chars)')
3266
+ .action(async (id, opts) => {
3043
3267
  try {
3044
3268
  const data = await api('GET', `/api/pipeline-templates/${id}`);
3045
3269
  const t = data.template;
3270
+ // --json mode: emit definition JSON (same as tpl export)
3271
+ if (opts.json) {
3272
+ console.log(JSON.stringify(t.definition, null, 2));
3273
+ return;
3274
+ }
3046
3275
  console.log(chalk.cyan(t.name));
3047
3276
  console.log(` ID: ${t.id}`);
3048
3277
  console.log(` Status: ${t.status}`);
@@ -3063,7 +3292,9 @@ templates
3063
3292
  for (const step of phase.steps || []) {
3064
3293
  const reviewer = step.reviewer ? ` reviewer:${step.reviewer}` : '';
3065
3294
  const cycles = step.reviewer && step.max_review_cycles ? ` max-cycles:${step.max_review_cycles}` : '';
3066
- console.log(` Step: ${step.name} [${step.role || 'unassigned'}] (${step.step_type || step.stepType || 'human'})${reviewer}${cycles}`);
3295
+ // gate and human_approval are equivalent; normalise display
3296
+ const displayType = step.step_type || step.stepType || 'human';
3297
+ console.log(` Step: ${step.name} [${step.role || 'unassigned'}] (${displayType})${reviewer}${cycles}`);
3067
3298
  if (step.outputs?.length) {
3068
3299
  console.log(' Outputs:');
3069
3300
  for (const o of step.outputs) {
@@ -3125,9 +3356,101 @@ templates
3125
3356
  if (ac.repos)
3126
3357
  console.log(` repos: ${JSON.stringify(ac.repos)}`);
3127
3358
  }
3359
+ // sections (agent instructions) — truncated at 200 chars unless --show-sections
3360
+ if (step.sections) {
3361
+ const full = step.sections;
3362
+ if (opts.showSections || full.length <= 200) {
3363
+ console.log(` Sections:\n${full.split('\n').map((l) => ` ${l}`).join('\n')}`);
3364
+ }
3365
+ else {
3366
+ console.log(` Sections: ${full.slice(0, 200)}... (${full.length} chars total)`);
3367
+ }
3368
+ }
3369
+ // verification block
3370
+ if (step.verification) {
3371
+ console.log(` Verification: ${JSON.stringify(step.verification)}`);
3372
+ }
3373
+ // uat block
3374
+ if (step.uat) {
3375
+ console.log(` UAT: ${JSON.stringify(step.uat)}`);
3376
+ }
3377
+ }
3378
+ }
3379
+ }
3380
+ }
3381
+ catch (err) {
3382
+ console.error(chalk.red(err.message));
3383
+ process.exit(1);
3384
+ }
3385
+ });
3386
+ templates
3387
+ .command('export')
3388
+ .description('Export a template\'s full definition JSON to stdout or a file. Includes all phases, steps, sections, verification, uat, agent_config, I/O wires, and pipeline inputs. The exported JSON is the canonical input to "tpl update-definition". Round-trip: tpl export <id> > def.json && tpl update-definition <id> def.json is a no-op.')
3389
+ .argument('<id>', 'Template ID (use "tpl list" to find IDs)')
3390
+ .option('--out <file>', 'Write output to file instead of stdout')
3391
+ .action(async (id, opts) => {
3392
+ try {
3393
+ const data = await api('GET', `/api/pipeline-templates/${id}`);
3394
+ const t = data.template;
3395
+ const json = JSON.stringify(t.definition, null, 2);
3396
+ if (opts.out) {
3397
+ const outPath = resolve(opts.out);
3398
+ writeFileSync(outPath, json, 'utf-8');
3399
+ console.log(chalk.green(`Definition written to ${outPath}`));
3400
+ }
3401
+ else {
3402
+ console.log(json);
3403
+ }
3404
+ }
3405
+ catch (err) {
3406
+ console.error(chalk.red(err.message));
3407
+ process.exit(1);
3408
+ }
3409
+ });
3410
+ templates
3411
+ .command('clone')
3412
+ .description('Clone a template into a new draft. The cloned template has the same definition as the source (all phases, steps, sections, verification, uat, I/O wires) but fresh UUIDs for all step/phase IDs. The source template is unchanged. After cloning, use "tpl validate <new-id>" to verify, then "tpl activate" when ready.')
3413
+ .argument('<sourceId>', 'Source template ID to clone from')
3414
+ .requiredOption('--name <name>', 'Name for the new draft template')
3415
+ .option('--description <desc>', 'Description for the new draft template')
3416
+ .action(async (sourceId, opts) => {
3417
+ try {
3418
+ // Fetch source definition
3419
+ const data = await api('GET', `/api/pipeline-templates/${sourceId}`);
3420
+ const src = data.template;
3421
+ // Deep-clone and remap all step/phase UUIDs so no shared IDs exist
3422
+ const srcDef = JSON.parse(JSON.stringify(src.definition));
3423
+ // Build a UUID remap table: old id → new id for phases and steps
3424
+ const idMap = new Map();
3425
+ for (const phase of srcDef.phases || []) {
3426
+ idMap.set(phase.id, crypto.randomUUID());
3427
+ for (const step of phase.steps || []) {
3428
+ idMap.set(step.id, crypto.randomUUID());
3429
+ }
3430
+ }
3431
+ // Apply remapping throughout the definition
3432
+ for (const phase of srcDef.phases || []) {
3433
+ phase.id = idMap.get(phase.id);
3434
+ for (const step of phase.steps || []) {
3435
+ step.id = idMap.get(step.id);
3436
+ // Remap step input source_step_id references
3437
+ for (const inp of step.inputs || []) {
3438
+ if (inp.source_step_id && idMap.has(inp.source_step_id)) {
3439
+ inp.source_step_id = idMap.get(inp.source_step_id);
3440
+ }
3128
3441
  }
3129
3442
  }
3130
3443
  }
3444
+ // Create new draft template
3445
+ const body = { name: opts.name, definition: srcDef };
3446
+ if (opts.description)
3447
+ body.description = opts.description;
3448
+ const created = await api('POST', '/api/pipeline-templates', body);
3449
+ const newTpl = created.template;
3450
+ console.log(chalk.green(`Cloned: ${newTpl.name}`));
3451
+ console.log(` ID: ${newTpl.id}`);
3452
+ console.log(` Source: ${src.name} (${sourceId})`);
3453
+ console.log(chalk.dim(` Run "fazemos tpl validate ${newTpl.id}" to verify the clone.`));
3131
3454
  }
3132
3455
  catch (err) {
3133
3456
  console.error(chalk.red(err.message));
@@ -3432,7 +3755,7 @@ templates
3432
3755
  .argument('<templateId>', 'Template ID')
3433
3756
  .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl phases", "tpl show", or "tpl add-phase" output)')
3434
3757
  .requiredOption('--name <name>', 'Step name (must be unique within the phase)')
3435
- .option('--type <type>', 'Step type: human (manual task), agent (AI agent), script (automated), gate (approval checkpoint)', 'human')
3758
+ .option('--type <type>', 'Step type: human (manual task), agent (AI agent), script (automated), gate/human_approval (approval checkpoint — gate and human_approval are equivalent aliases)', 'human')
3436
3759
  .option('--role <role>', 'Role or agent name (e.g., "kate", "marco", "dev-team")')
3437
3760
  .option('--description <desc>', 'Step description')
3438
3761
  .option('--reviewer <reviewer>', 'Reviewer role for review steps')
@@ -3449,6 +3772,8 @@ templates
3449
3772
  .option('--agent-config <json>', 'Per-step agent config overrides as JSON (e.g., \'{"model":"opus","maxBudgetUsd":20}\'). Overrides agent member defaults for: model, maxBudgetUsd, maxTurns, timeoutMs, cwd, repos')
3450
3773
  .option('--stream-silence-abort-ms <ms>', 'Stream silence abort threshold in milliseconds (30000-1800000)', parseStreamSilenceAbortMs)
3451
3774
  .option('--eval-max-turns <n>', 'Evaluator turn budget for tool-enabled (agent) evals on this step (8-30). Has no effect on script-typed steps (rejected by API).', parseEvalMaxTurns)
3775
+ .option('--verification <json>', 'Step-level verification config as JSON or @filepath (e.g., \'{"type":"endpoint","target":"{{pipeline.backend_endpoint_target}}",...}\'). Used for TOOL-3 Tooth-1 deploy-guard verification.')
3776
+ .option('--uat <json>', 'Step-level UAT config as JSON or @filepath (e.g., \'{"primary_path":"{{pipeline.uat_primary_path}}","reachable_env":"dev",...}\'). Used for TOOL-3 Tooth-3 UAT replay.')
3452
3777
  .action(async (templateId, opts) => {
3453
3778
  try {
3454
3779
  if (!VALID_STEP_TYPES.includes(opts.type)) {
@@ -3512,6 +3837,16 @@ templates
3512
3837
  step.stream_silence_abort_ms = opts.streamSilenceAbortMs;
3513
3838
  if (opts.evalMaxTurns !== undefined)
3514
3839
  step.eval_max_turns = opts.evalMaxTurns;
3840
+ if (opts.verification != null) {
3841
+ const v = parseJsonOrFile(opts.verification, '--verification');
3842
+ if (v !== null)
3843
+ step.verification = v;
3844
+ }
3845
+ if (opts.uat != null) {
3846
+ const u = parseJsonOrFile(opts.uat, '--uat');
3847
+ if (u !== null)
3848
+ step.uat = u;
3849
+ }
3515
3850
  // Positioning: --after or --sort-order
3516
3851
  if (opts.after && opts.sortOrder !== undefined) {
3517
3852
  console.error(chalk.red('Cannot use both --after and --sort-order'));
@@ -3598,7 +3933,7 @@ templates
3598
3933
  .argument('<templateId>', 'Template ID')
3599
3934
  .requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
3600
3935
  .option('--name <name>', 'New step name (must be unique within the phase)')
3601
- .option('--type <type>', 'Step type: human, agent, script, gate')
3936
+ .option('--type <type>', 'Step type: human, agent, script, gate/human_approval (gate and human_approval are equivalent aliases for approval checkpoint steps)')
3602
3937
  .option('--role <role>', 'Role or agent name')
3603
3938
  .option('--description <desc>', 'Description')
3604
3939
  .option('--reviewer <reviewer>', 'Reviewer role (empty string to clear)')
@@ -3614,6 +3949,8 @@ templates
3614
3949
  .option('--stream-silence-abort-ms <ms>', 'Stream silence abort threshold in milliseconds (30000-1800000)', parseStreamSilenceAbortMs)
3615
3950
  .option('--eval-max-turns <n>', 'Evaluator turn budget for tool-enabled (agent) evals on this step (8-30). Mutually exclusive with --clear-eval-max-turns.', parseEvalMaxTurns)
3616
3951
  .option('--clear-eval-max-turns', 'Remove a previously-set eval_max_turns from this step, reverting to runner default. Mutually exclusive with --eval-max-turns.')
3952
+ .option('--verification <json>', 'Step-level verification config as JSON or @filepath. Empty string clears the field. (e.g., \'{"type":"endpoint","target":"{{pipeline.backend_endpoint_target}}",...}\')')
3953
+ .option('--uat <json>', 'Step-level UAT config as JSON or @filepath. Empty string clears the field. (e.g., \'{"primary_path":"{{pipeline.uat_primary_path}}","reachable_env":"dev",...}\')')
3617
3954
  .action(async (templateId, opts) => {
3618
3955
  try {
3619
3956
  if (opts.evalMaxTurns !== undefined && opts.clearEvalMaxTurns) {
@@ -3625,7 +3962,8 @@ templates
3625
3962
  || opts.reviewer != null || opts.maxReviewCycles != null || opts.parallelGroup != null
3626
3963
  || opts.image || opts.command || opts.timeout !== undefined || opts.workingDir || opts.env
3627
3964
  || opts.agentConfig || opts.sections != null || opts.streamSilenceAbortMs !== undefined
3628
- || opts.evalMaxTurns !== undefined || opts.clearEvalMaxTurns;
3965
+ || opts.evalMaxTurns !== undefined || opts.clearEvalMaxTurns
3966
+ || opts.verification != null || opts.uat != null;
3629
3967
  if (!hasUpdate) {
3630
3968
  console.error(chalk.red('Provide at least one field to update'));
3631
3969
  process.exit(1);
@@ -3705,6 +4043,26 @@ templates
3705
4043
  else if (opts.clearEvalMaxTurns) {
3706
4044
  delete step.eval_max_turns;
3707
4045
  }
4046
+ // verification block — empty string clears, any other value replaces
4047
+ if (opts.verification != null) {
4048
+ const v = parseJsonOrFile(opts.verification, '--verification');
4049
+ if (v === null) {
4050
+ delete step.verification;
4051
+ }
4052
+ else {
4053
+ step.verification = v;
4054
+ }
4055
+ }
4056
+ // uat block — empty string clears, any other value replaces
4057
+ if (opts.uat != null) {
4058
+ const u = parseJsonOrFile(opts.uat, '--uat');
4059
+ if (u === null) {
4060
+ delete step.uat;
4061
+ }
4062
+ else {
4063
+ step.uat = u;
4064
+ }
4065
+ }
3708
4066
  await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
3709
4067
  console.log(chalk.green(`Updated step: ${step.name}`));
3710
4068
  }
@@ -5828,25 +6186,127 @@ async function resolveAgentId(nameOrId) {
5828
6186
  }
5829
6187
  agentsCmd
5830
6188
  .command('register')
5831
- .description('Register an agent')
6189
+ .description('Register an agent and optionally provision an API credential (fzm_ key) in the same call. ' +
6190
+ 'Credential provisioning is on by default (pass --no-provision-credential to opt out). ' +
6191
+ 'Owner/admin only when provisioning credentials; agent identities are rejected.')
5832
6192
  .requiredOption('-n, --name <name>', 'Agent display name')
5833
6193
  .option('-r, --roles <roles>', 'Comma-separated roles', (v) => v.split(','))
5834
6194
  .option('--model <model>', 'Model (e.g., opus, sonnet, system)', 'sonnet')
6195
+ .option('--project <slug>', 'Project slug to scope the credential to (defaults to active project). Required when provisioning a credential.')
6196
+ .option('--no-provision-credential', 'Skip credential provisioning. Default: credential IS provisioned (pass this flag to opt out).')
6197
+ .option('--force-rotate', 'Rotate an existing credential — revoke the old key and mint a new one in the same transaction.')
6198
+ .option('--json', 'Emit JSON instead of human-readable output')
6199
+ // NOTE: --no-provision-credential is the sole option definition; Commander v12 automatically
6200
+ // creates --provision-credential as the positive form and defaults provisionCredential to true.
5835
6201
  .action(async (opts) => {
5836
6202
  try {
6203
+ // F42 — CLI defaults --provision-credential to true (Sage UX lock).
6204
+ // opts.provisionCredential is `true` unless --no-provision-credential was passed (sets it to false).
6205
+ const provisionCredential = opts.provisionCredential !== false;
6206
+ // Resolve credentialProjectId from --project or active project when provisioning.
6207
+ let credentialProjectId;
6208
+ if (provisionCredential) {
6209
+ if (opts.project) {
6210
+ // Resolve slug → id via cache; refresh once on miss.
6211
+ const orgId = getActiveOrgId();
6212
+ if (orgId) {
6213
+ let proj = findProjectBySlug(orgId, opts.project);
6214
+ if (!proj) {
6215
+ await refreshAuthMeCache();
6216
+ proj = findProjectBySlug(orgId, opts.project);
6217
+ }
6218
+ if (proj) {
6219
+ credentialProjectId = proj.id;
6220
+ }
6221
+ else {
6222
+ const msg = `Unknown project: ${opts.project}`;
6223
+ if (opts.json) {
6224
+ console.log(JSON.stringify({ error: { code: 'UNKNOWN_PROJECT', message: msg } }));
6225
+ }
6226
+ else {
6227
+ console.error(chalk.red(msg));
6228
+ console.log(chalk.gray('Run: fazemos projects list'));
6229
+ }
6230
+ process.exit(1);
6231
+ return; // guard: process.exit is mocked in tests
6232
+ }
6233
+ }
6234
+ }
6235
+ else {
6236
+ credentialProjectId = getActiveProjectId() ?? undefined;
6237
+ }
6238
+ if (!credentialProjectId) {
6239
+ const msg = 'No active Project. Pass --project <slug> or fazemos projects switch <slug>.';
6240
+ if (opts.json) {
6241
+ console.log(JSON.stringify({ error: { code: 'NO_ACTIVE_PROJECT', message: msg } }));
6242
+ }
6243
+ else {
6244
+ console.error(chalk.red(msg));
6245
+ }
6246
+ process.exit(1);
6247
+ return; // guard: process.exit is mocked in tests
6248
+ }
6249
+ }
6250
+ const agentEntry = {
6251
+ displayName: opts.name,
6252
+ roles: opts.roles || [opts.name],
6253
+ config: { model: opts.model },
6254
+ provisionCredential,
6255
+ };
6256
+ if (provisionCredential) {
6257
+ agentEntry.credentialProjectId = credentialProjectId;
6258
+ if (opts.forceRotate)
6259
+ agentEntry.forceRotate = true;
6260
+ }
5837
6261
  const data = await api('POST', '/api/agents/register', {
5838
- agents: [{
5839
- displayName: opts.name,
5840
- roles: opts.roles || [opts.name],
5841
- config: { model: opts.model },
5842
- }],
6262
+ agents: [agentEntry],
5843
6263
  });
6264
+ if (opts.json) {
6265
+ console.log(JSON.stringify({ agents: data.agents }));
6266
+ return;
6267
+ }
5844
6268
  for (const a of data.agents) {
5845
6269
  const icon = a.status === 'created' ? chalk.green('✓') : a.status === 'updated' ? chalk.yellow('↻') : '○';
5846
6270
  console.log(` ${icon} ${a.display_name} (${a.status}) — ${a.member_id}`);
6271
+ // F42 — credential block (matches `fazemos api-keys create` rendering per Sage UX)
6272
+ if (a.credential) {
6273
+ const cred = a.credential;
6274
+ if (cred.status === 'minted' || cred.status === 'rotated') {
6275
+ console.log('');
6276
+ console.log(chalk.green(` API credential ${cred.status}: ${opts.name}-key`));
6277
+ console.log(` Key: ${chalk.yellow(cred.rawKey)}`);
6278
+ console.log(` ID: ${cred.apiKeyId}`);
6279
+ console.log('');
6280
+ console.log(chalk.red(' ⚠ Save this key now — it will not be shown again.'));
6281
+ }
6282
+ else if (cred.status === 'exists') {
6283
+ console.log(chalk.gray(` (credential already provisioned — use --force-rotate to rotate)`));
6284
+ }
6285
+ }
6286
+ if (a.auditLogId) {
6287
+ console.log(chalk.gray(` Audit: ${a.auditLogId}`));
6288
+ }
5847
6289
  }
5848
6290
  }
5849
6291
  catch (err) {
6292
+ if (opts.json) {
6293
+ console.log(JSON.stringify({ error: { code: err.code ?? 'ERROR', message: err.message } }));
6294
+ process.exit(1);
6295
+ }
6296
+ if (err instanceof ApiError) {
6297
+ if (err.code === 'FORBIDDEN_ROLE') {
6298
+ console.error(chalk.red('Access denied. Owner or admin role required to provision credentials. Agent identities are not permitted.'));
6299
+ process.exit(1);
6300
+ }
6301
+ if (err.code === 'CREDENTIAL_PROJECT_REQUIRED') {
6302
+ console.error(chalk.red('credentialProjectId is required when provisionCredential=true. Pass --project <slug>.'));
6303
+ process.exit(1);
6304
+ }
6305
+ if (err.code === 'CREDENTIAL_PROVISION_FAILED') {
6306
+ console.error(chalk.red('Credential provisioning failed — transaction rolled back. Try again or contact your admin.'));
6307
+ process.exit(1);
6308
+ }
6309
+ }
5850
6310
  console.error(chalk.red(err.message));
5851
6311
  process.exit(1);
5852
6312
  }