@formigio/fazemos-cli 0.10.35 → 0.10.38

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
@@ -3,9 +3,12 @@ import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironment, hasEnvironments,
5
5
  // F15 — project context helpers
6
- getActiveProjectId, setActiveProjectId, clearActiveProjectId, findProjectBySlug, findProjectById, findOrgById, } from './config.js';
6
+ getActiveProjectId, setActiveProjectId, clearActiveProjectId, findProjectBySlug, findProjectById, findOrgById,
7
+ // Directory-scoped context override (.fazemos.json)
8
+ setOrgOverride, setProjectOverride, setDirContextSource, getDirContextSource, } from './config.js';
9
+ import { findDirContextFile, readDirContextFile, writeDirContextFile, removeDirContextFile, DIR_CONFIG_FILENAME, } from './dircontext.js';
7
10
  import { login, signup, confirmSignup, adminLogin } from './auth.js';
8
- import { api, ApiError, refreshAuthMeCache, invalidateAuthMeCache, resolveOrgIdMaybeSlug } from './api.js';
11
+ import { api, ApiError, refreshAuthMeCache, invalidateAuthMeCache, resolveOrgIdMaybeSlug, resolveProjectIdBySlug } from './api.js';
9
12
  import { isProjectConnectionUnavailable, renderProjectConnectionUnavailableCopy, } from './connectionErrorCopy.js';
10
13
  import { loadYaml, summarize } from './yaml/load.js';
11
14
  import { printFindings, printJson } from './yaml/format.js';
@@ -21,7 +24,7 @@ import { registerGovernanceCommands } from './governance.js';
21
24
  import { parseExecutionsJson, resolveWaitOptions, waitForPipelines, buildAwsCommand, validateExecutionEntry, } from './wait-for-pipeline.js';
22
25
  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
23
26
  import { fileURLToPath } from 'url';
24
- import { dirname, resolve, basename } from 'path';
27
+ import { dirname, resolve, basename, relative } from 'path';
25
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
29
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
27
30
  function parseNumber(val) {
@@ -110,6 +113,32 @@ function parseJson(val, flag) {
110
113
  process.exit(1);
111
114
  }
112
115
  }
116
+ /**
117
+ * Parse JSON from an inline string or `@filepath`. Empty string returns null (clears the field).
118
+ * Used by --verification and --uat flags.
119
+ */
120
+ function parseJsonOrFile(val, flag) {
121
+ if (val === '')
122
+ return null;
123
+ let raw = val;
124
+ if (val.startsWith('@')) {
125
+ const filePath = resolve(val.slice(1));
126
+ try {
127
+ raw = readFileSync(filePath, 'utf-8');
128
+ }
129
+ catch (err) {
130
+ console.error(chalk.red(`Cannot read ${flag} file "${filePath}": ${err.message}`));
131
+ process.exit(1);
132
+ }
133
+ }
134
+ try {
135
+ return JSON.parse(raw);
136
+ }
137
+ catch {
138
+ console.error(chalk.red(`Invalid JSON for ${flag}. Check quoting and syntax.`));
139
+ process.exit(1);
140
+ }
141
+ }
113
142
  /** Resolve a value that may be `@filepath` (reads file contents) or inline text. */
114
143
  function resolveFileOrInline(val) {
115
144
  if (val.startsWith('@')) {
@@ -167,6 +196,81 @@ program
167
196
  .name('fazemos')
168
197
  .description('Fazemos CLI — Team Accomplishment Platform')
169
198
  .version(pkg.version);
199
+ // ── Directory-scoped default org/project (.fazemos.json) ─────
200
+ //
201
+ // Before any command action runs, discover the nearest `.fazemos.json`
202
+ // (walking up from the cwd) and resolve its org/project slugs to ids, holding
203
+ // them as in-memory overrides for the process. This scopes every call to the
204
+ // pinned org/project without a manual `orgs switch` / `projects switch`.
205
+ //
206
+ // Resolution is best-effort: if a slug can't be resolved (not logged in, slug
207
+ // not in your memberships, stale cache) we leave the override unset and let the
208
+ // normal active-selection + API error path take over. Network resolution is
209
+ // skipped for the bootstrap commands that don't need a scoped context.
210
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
211
+ /** Top-level commands that never need (and shouldn't trigger) context resolution. */
212
+ const DIR_CONTEXT_SKIP = new Set(['use', 'auth', 'env', 'init']);
213
+ let dirContextApplied = false;
214
+ async function applyDirContext(topCommand) {
215
+ if (dirContextApplied)
216
+ return;
217
+ dirContextApplied = true;
218
+ let found;
219
+ try {
220
+ found = findDirContextFile();
221
+ }
222
+ catch (err) {
223
+ // Malformed .fazemos.json — surface it for normal commands, but don't
224
+ // abort. The bootstrap commands (notably `use`) report it themselves, so
225
+ // stay silent for them to avoid a double warning.
226
+ if (!DIR_CONTEXT_SKIP.has(topCommand))
227
+ console.error(chalk.yellow(err.message));
228
+ return;
229
+ }
230
+ if (!found)
231
+ return;
232
+ // Record the source so whoami/config can show where the scope came from.
233
+ setDirContextSource({ path: found.path, ...found.context });
234
+ // Bootstrap commands (auth/env/init/use) operate without a resolved scope.
235
+ if (DIR_CONTEXT_SKIP.has(topCommand))
236
+ return;
237
+ const { org, project } = found.context;
238
+ if (org) {
239
+ try {
240
+ setOrgOverride(await resolveOrgIdMaybeSlug(org));
241
+ }
242
+ catch {
243
+ // Unresolved org slug — leave unset; the command's own "no active org"
244
+ // check or the API will report the problem with full context.
245
+ }
246
+ }
247
+ if (project) {
248
+ try {
249
+ if (UUID_RE.test(project)) {
250
+ setProjectOverride(project);
251
+ }
252
+ else {
253
+ // Resolves against the active org, which now reflects the org override.
254
+ const projectId = await resolveProjectIdBySlug(project);
255
+ if (projectId)
256
+ setProjectOverride(projectId);
257
+ }
258
+ }
259
+ catch {
260
+ // Unresolved project slug — leave unset; surfaced downstream.
261
+ }
262
+ }
263
+ }
264
+ program.hook('preAction', async (_thisCommand, actionCommand) => {
265
+ // Resolve the top-level command name (e.g. `worksheets list` → `worksheets`).
266
+ let c = actionCommand;
267
+ let top = c?.name?.();
268
+ while (c?.parent && c.parent !== program) {
269
+ c = c.parent;
270
+ top = c.name();
271
+ }
272
+ await applyDirContext(top ?? '');
273
+ });
170
274
  // ── Init ────────────────────────────────────────────────────
171
275
  program
172
276
  .command('init')
@@ -375,6 +479,94 @@ program
375
479
  else {
376
480
  console.log(` Project: ${chalk.gray('(none set — fazemos projects switch <slug>)')}`);
377
481
  }
482
+ // Note when the org/project scope is coming from a directory .fazemos.json
483
+ // rather than the persisted active selection, so the source is obvious.
484
+ const dirSource = getDirContextSource();
485
+ if (dirSource) {
486
+ const rel = relative(process.cwd(), dirSource.path) || dirSource.path;
487
+ console.log(chalk.gray(` Scope: from ${rel} (fazemos use)`));
488
+ }
489
+ }
490
+ catch (err) {
491
+ console.error(chalk.red(err.message));
492
+ process.exit(1);
493
+ }
494
+ });
495
+ // ── Directory context (`fazemos use`) ───────────────────────
496
+ program
497
+ .command('use')
498
+ .description(`Set the default org/project for the current directory (writes ${DIR_CONFIG_FILENAME}). Running the CLI anywhere under that directory scopes calls to this org/project automatically.`)
499
+ .option('-o, --org <slug>', 'Org slug to pin for this directory')
500
+ .option('-p, --project <slug>', 'Project slug to pin for this directory')
501
+ .option('--show', 'Show the directory context in effect and which file provides it')
502
+ .option('--clear', `Remove ${DIR_CONFIG_FILENAME} from the current directory`)
503
+ .action(async (opts) => {
504
+ try {
505
+ // --show (also the default when no flags are given): report the nearest
506
+ // .fazemos.json and the effective scope.
507
+ if (opts.show || (!opts.org && !opts.project && !opts.clear)) {
508
+ const found = findDirContextFile();
509
+ if (!found) {
510
+ console.log(chalk.gray(`No ${DIR_CONFIG_FILENAME} found in this directory or any parent.`));
511
+ console.log(chalk.gray('Set one with: fazemos use --org <slug> --project <slug>'));
512
+ return;
513
+ }
514
+ const rel = relative(process.cwd(), found.path) || found.path;
515
+ console.log(`Directory context (${chalk.cyan(rel)}):`);
516
+ console.log(` Org: ${found.context.org ? chalk.cyan(found.context.org) : chalk.gray('(not set)')}`);
517
+ console.log(` Project: ${found.context.project ? chalk.cyan(found.context.project) : chalk.gray('(not set)')}`);
518
+ return;
519
+ }
520
+ // --clear: remove the .fazemos.json in the cwd (not a parent's).
521
+ if (opts.clear) {
522
+ const removed = removeDirContextFile(process.cwd());
523
+ if (removed) {
524
+ console.log(chalk.green(`Removed ${DIR_CONFIG_FILENAME} from this directory.`));
525
+ }
526
+ else {
527
+ console.log(chalk.gray(`No ${DIR_CONFIG_FILENAME} in this directory to remove.`));
528
+ }
529
+ return;
530
+ }
531
+ // Merge onto any existing file in the cwd so `use --project x` keeps a
532
+ // previously-pinned org (and vice versa).
533
+ const existing = readDirContextFile(process.cwd())?.context ?? {};
534
+ const orgSlug = opts.org ?? existing.org;
535
+ const projectSlug = opts.project ?? existing.project;
536
+ if (opts.project && !orgSlug) {
537
+ console.error(chalk.red('Cannot pin a project without an org. Pass --org <slug> too.'));
538
+ process.exit(1);
539
+ }
540
+ // Validate the org slug against your memberships (resolves + caches
541
+ // /auth/me). resolveOrgIdMaybeSlug throws a clear error on an unknown slug.
542
+ let orgName = orgSlug;
543
+ let resolvedOrgId = null;
544
+ if (orgSlug) {
545
+ resolvedOrgId = await resolveOrgIdMaybeSlug(orgSlug);
546
+ orgName = findOrgById(resolvedOrgId)?.name ?? orgSlug;
547
+ }
548
+ // Validate the project slug within that org.
549
+ let projectName = projectSlug;
550
+ if (projectSlug && resolvedOrgId) {
551
+ let project = findProjectBySlug(resolvedOrgId, projectSlug);
552
+ if (!project) {
553
+ await refreshAuthMeCache();
554
+ project = findProjectBySlug(resolvedOrgId, projectSlug);
555
+ }
556
+ if (!project) {
557
+ console.error(chalk.red(`Unknown project "${projectSlug}" in org ${orgName}.`));
558
+ console.log(chalk.gray('Run: fazemos projects list'));
559
+ process.exit(1);
560
+ }
561
+ projectName = project.name;
562
+ }
563
+ const path = writeDirContextFile(process.cwd(), { org: orgSlug, project: projectSlug });
564
+ const rel = relative(process.cwd(), path) || path;
565
+ console.log(chalk.green(`✓ Wrote ${rel}`));
566
+ if (orgSlug)
567
+ console.log(` Org: ${chalk.cyan(orgName)} (${orgSlug})`);
568
+ if (projectSlug)
569
+ console.log(` Project: ${chalk.cyan(projectName)} (${projectSlug})`);
378
570
  }
379
571
  catch (err) {
380
572
  console.error(chalk.red(err.message));
@@ -1108,7 +1300,8 @@ projects
1108
1300
  });
1109
1301
  projects
1110
1302
  .command('create')
1111
- .description('Create a new project in the active organization. Owner/admin only. Slug is immutable after creation — pick carefully.')
1303
+ .description('Create a new project in the active organization. Owner/admin only. Slug is immutable after creation — pick carefully.\n\n' +
1304
+ 'Next: `fazemos projects setup <slug>` to bind docs and connection.')
1112
1305
  .argument('<slug>', 'URL slug (lowercase letters, numbers, hyphens; 1-32 chars). Used in URLs and CLI config — cannot be changed.')
1113
1306
  .requiredOption('-n, --name <name>', 'Human-readable name (max 200 chars)')
1114
1307
  .option('-d, --description <desc>', 'Optional description (max 2000 chars)')
@@ -1337,7 +1530,8 @@ projects
1337
1530
  // ── F16 — projects set-connection (binds a Connection to a project) ─
1338
1531
  projects
1339
1532
  .command('set-connection')
1340
- .description('Bind a GitHub Connection to a project. Pass "none" to unbind. Owner/admin only.')
1533
+ .description('If this is a fresh project, use `fazemos projects setup` instead that command binds docs + connection together.\n\n' +
1534
+ 'Bind a GitHub Connection to a project. Pass "none" to unbind. Owner/admin only.')
1341
1535
  .argument('<slug>', 'Project slug')
1342
1536
  .argument('<connection>', 'Connection ID, or "none" to unbind')
1343
1537
  .action(async (slug, connection) => {
@@ -1390,6 +1584,200 @@ projects
1390
1584
  process.exit(1);
1391
1585
  }
1392
1586
  });
1587
+ // ── F42 — projects setup (binds docs + connection in one call) ──────────────
1588
+ projects
1589
+ .command('setup')
1590
+ .description('Bind docs repo, docs root, and/or VCS connection to an existing project in one audited admin call. ' +
1591
+ 'Owner/admin only — agent identities are rejected. At least one flag required.')
1592
+ .argument('<slug>', 'Project slug')
1593
+ .option('--docs-repo <owner/repo>', 'Docs repository in <owner>/<repo> format (omit to leave unchanged)')
1594
+ .option('--docs-root <ROOT|path>', 'Docs root path. Pass ROOT (case-insensitive) for repo root. Omit to leave unchanged.')
1595
+ .option('--vcs-connection-id <uuid>', 'VCS connection UUID to bind (omit to leave unchanged)')
1596
+ .option('--json', 'Emit JSON instead of human-readable output')
1597
+ .action(async (slug, opts) => {
1598
+ // ── Client-side validation ──────────────────────────────────
1599
+ const hasDocsRepo = opts.docsRepo !== undefined;
1600
+ const hasDocsRoot = opts.docsRoot !== undefined;
1601
+ const hasVcsConnectionId = opts.vcsConnectionId !== undefined;
1602
+ if (!hasDocsRepo && !hasDocsRoot && !hasVcsConnectionId) {
1603
+ const msg = 'No flags passed. Provide at least one of --docs-repo, --docs-root, --vcs-connection-id.';
1604
+ if (opts.json) {
1605
+ console.log(JSON.stringify({ error: { code: 'NO_FLAGS_PASSED', message: msg } }));
1606
+ }
1607
+ else {
1608
+ console.error(chalk.red(msg));
1609
+ console.error(chalk.gray('Run: fazemos projects setup --help'));
1610
+ }
1611
+ process.exit(1);
1612
+ return; // guard: process.exit is mocked in tests
1613
+ }
1614
+ if (hasDocsRoot) {
1615
+ const rawRoot = opts.docsRoot;
1616
+ if (rawRoot.trim() === '') {
1617
+ const msg = 'docs_root_empty: --docs-root cannot be an empty string or whitespace. Pass ROOT for repo root or a valid subpath.';
1618
+ if (opts.json) {
1619
+ console.log(JSON.stringify({ error: { code: 'docs_root_empty', message: msg } }));
1620
+ }
1621
+ else {
1622
+ console.error(chalk.red(msg));
1623
+ console.error(chalk.gray('See: docs/architecture/repo-root-vs-subdir-docs.md'));
1624
+ }
1625
+ process.exit(1);
1626
+ return; // guard: process.exit is mocked in tests
1627
+ }
1628
+ if (rawRoot.length > 500) {
1629
+ const msg = `docs_root_too_long: --docs-root exceeds 500 characters (got ${rawRoot.length}).`;
1630
+ if (opts.json) {
1631
+ console.log(JSON.stringify({ error: { code: 'docs_root_too_long', message: msg } }));
1632
+ }
1633
+ else {
1634
+ console.error(chalk.red(msg));
1635
+ }
1636
+ process.exit(1);
1637
+ return; // guard: process.exit is mocked in tests
1638
+ }
1639
+ }
1640
+ if (hasDocsRepo && opts.docsRepo !== null) {
1641
+ const repoPattern = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
1642
+ if (!repoPattern.test(opts.docsRepo)) {
1643
+ const msg = `docs_repo_shape: --docs-repo must be in <owner>/<repo> format (got: ${opts.docsRepo}).`;
1644
+ if (opts.json) {
1645
+ console.log(JSON.stringify({ error: { code: 'docs_repo_shape', message: msg } }));
1646
+ }
1647
+ else {
1648
+ console.error(chalk.red(msg));
1649
+ }
1650
+ process.exit(1);
1651
+ return; // guard: process.exit is mocked in tests
1652
+ }
1653
+ }
1654
+ // ── Resolve slug → project ID ──────────────────────────────
1655
+ const orgId = requireActiveOrgOrExit();
1656
+ let project = findProjectBySlug(orgId, slug);
1657
+ if (!project) {
1658
+ await refreshAuthMeCache();
1659
+ project = findProjectBySlug(orgId, slug);
1660
+ }
1661
+ if (!project) {
1662
+ const msg = `Unknown project: ${slug}`;
1663
+ if (opts.json) {
1664
+ console.log(JSON.stringify({ error: { code: 'PROJECT_NOT_FOUND', message: msg } }));
1665
+ }
1666
+ else {
1667
+ console.error(chalk.red(msg));
1668
+ console.log(chalk.gray('Run: fazemos projects list'));
1669
+ }
1670
+ process.exit(1);
1671
+ return; // guard: process.exit is mocked in tests
1672
+ }
1673
+ // ── Build request body ─────────────────────────────────────
1674
+ // OQ-3: ROOT (case-insensitive) → null (means "repo root" at API)
1675
+ const body = {};
1676
+ if (hasDocsRepo)
1677
+ body.docsRepo = opts.docsRepo ?? null;
1678
+ if (hasDocsRoot) {
1679
+ body.docsRoot = opts.docsRoot.toUpperCase() === 'ROOT' ? null : opts.docsRoot;
1680
+ }
1681
+ if (hasVcsConnectionId)
1682
+ body.vcsConnectionId = opts.vcsConnectionId ?? null;
1683
+ // ── API call ───────────────────────────────────────────────
1684
+ try {
1685
+ const data = await api('POST', `/api/admin/projects/${project.id}/setup`, body, { noProjectHeader: true });
1686
+ if (opts.json) {
1687
+ console.log(JSON.stringify({
1688
+ status: 'ok',
1689
+ project: data.project,
1690
+ auditLogId: data.auditLogId,
1691
+ changes: data.changes,
1692
+ }));
1693
+ return;
1694
+ }
1695
+ // ── Human-readable success output (Sage UX §"Success output") ──
1696
+ const p = data.project;
1697
+ const changes = data.changes ?? {};
1698
+ const changedFields = Object.keys(changes);
1699
+ const passedFields = Object.keys(body);
1700
+ const unchangedFields = passedFields.filter(f => !changedFields.includes(f));
1701
+ console.log(chalk.green(`✓ ${p.name} (${p.slug}) — onboarded`));
1702
+ if (changedFields.includes('docsRepo') || passedFields.includes('docsRepo')) {
1703
+ const val = p.docsRepo ?? '(cleared)';
1704
+ const marker = changedFields.includes('docsRepo') ? '' : chalk.gray(' (unchanged)');
1705
+ console.log(` docs_repo: ${val}${marker}`);
1706
+ }
1707
+ if (changedFields.includes('docsRoot') || passedFields.includes('docsRoot')) {
1708
+ const val = p.docsRoot === null ? 'ROOT (repo root — no subdirectory)' : p.docsRoot;
1709
+ const marker = changedFields.includes('docsRoot') ? '' : chalk.gray(' (unchanged)');
1710
+ console.log(` docs_root: ${val}${marker}`);
1711
+ }
1712
+ if (changedFields.includes('vcsConnectionId') || passedFields.includes('vcsConnectionId')) {
1713
+ const val = p.vcsConnectionId ?? '(cleared)';
1714
+ const marker = changedFields.includes('vcsConnectionId') ? '' : chalk.gray(' (unchanged)');
1715
+ console.log(` vcs_connection_id: ${val}${marker}`);
1716
+ }
1717
+ if (unchangedFields.length > 0) {
1718
+ console.log(chalk.gray(` (unchanged: ${unchangedFields.join(', ')})`));
1719
+ }
1720
+ console.log(` Audit: logged as project_setup (id: ${data.auditLogId})`);
1721
+ }
1722
+ catch (err) {
1723
+ if (opts.json) {
1724
+ console.log(JSON.stringify({ error: { code: err.code ?? 'ERROR', message: err.message } }));
1725
+ process.exit(1);
1726
+ }
1727
+ if (err instanceof ApiError) {
1728
+ switch (err.code) {
1729
+ case 'PROJECT_NOT_FOUND':
1730
+ console.error(chalk.red(`Project not found: ${slug}`));
1731
+ console.log(chalk.gray('Run: fazemos projects list'));
1732
+ break;
1733
+ case 'FORBIDDEN_ROLE':
1734
+ console.error(chalk.red('Access denied. Owner or admin role required. Agent identities are not permitted.'));
1735
+ break;
1736
+ case 'AT_LEAST_ONE_FIELD_REQUIRED':
1737
+ console.error(chalk.red('At least one of --docs-repo, --docs-root, --vcs-connection-id must be provided.'));
1738
+ break;
1739
+ case 'VALIDATION_ERROR': {
1740
+ const body2 = (err.body ?? {});
1741
+ const sub = body2.subCode ?? '';
1742
+ if (sub === 'docs_root_empty') {
1743
+ console.error(chalk.red('docs_root_empty: --docs-root cannot be empty or whitespace. Pass ROOT for repo root.'));
1744
+ console.error(chalk.gray('See: docs/architecture/repo-root-vs-subdir-docs.md'));
1745
+ }
1746
+ else if (sub === 'docs_root_too_long') {
1747
+ console.error(chalk.red('docs_root_too_long: --docs-root exceeds 500 characters.'));
1748
+ }
1749
+ else if (sub === 'docs_repo_shape') {
1750
+ console.error(chalk.red('docs_repo_shape: --docs-repo must be in <owner>/<repo> format.'));
1751
+ }
1752
+ else if (sub === 'vcs_connection_uuid') {
1753
+ console.error(chalk.red('vcs_connection_uuid: --vcs-connection-id must be a valid UUID.'));
1754
+ }
1755
+ else {
1756
+ console.error(chalk.red(`Validation error: ${err.message}`));
1757
+ }
1758
+ break;
1759
+ }
1760
+ case 'CONNECTION_NOT_FOUND':
1761
+ console.error(chalk.red('VCS connection not found.'));
1762
+ console.log(chalk.gray('List your connections: fazemos connections list'));
1763
+ break;
1764
+ case 'CONNECTION_CROSS_ORG':
1765
+ console.error(chalk.red('That VCS connection belongs to a different organization.'));
1766
+ break;
1767
+ case 'CONNECTION_NOT_ACTIVE':
1768
+ console.error(chalk.red('VCS connection is not active and cannot be bound.'));
1769
+ console.log(chalk.gray('Pick an active connection: fazemos connections list'));
1770
+ break;
1771
+ default:
1772
+ console.error(chalk.red(err.message));
1773
+ }
1774
+ }
1775
+ else {
1776
+ console.error(chalk.red(err.message));
1777
+ }
1778
+ process.exit(1);
1779
+ }
1780
+ });
1393
1781
  // ── F16 — Connections ───────────────────────────────────────
1394
1782
  const connections = program.command('connections').alias('conn').description('GitHub Connection commands');
1395
1783
  /**
@@ -3039,10 +3427,17 @@ templates
3039
3427
  .command('show')
3040
3428
  .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
3429
  .argument('<id>', 'Template ID (use "tpl list" to find IDs)')
3042
- .action(async (id) => {
3430
+ .option('--json', 'Output the template definition as JSON (same as tpl export). Useful for piping/inspecting without a temp file.')
3431
+ .option('--show-sections', 'Print full sections content for all steps (default: truncated at 200 chars)')
3432
+ .action(async (id, opts) => {
3043
3433
  try {
3044
3434
  const data = await api('GET', `/api/pipeline-templates/${id}`);
3045
3435
  const t = data.template;
3436
+ // --json mode: emit definition JSON (same as tpl export)
3437
+ if (opts.json) {
3438
+ console.log(JSON.stringify(t.definition, null, 2));
3439
+ return;
3440
+ }
3046
3441
  console.log(chalk.cyan(t.name));
3047
3442
  console.log(` ID: ${t.id}`);
3048
3443
  console.log(` Status: ${t.status}`);
@@ -3063,7 +3458,9 @@ templates
3063
3458
  for (const step of phase.steps || []) {
3064
3459
  const reviewer = step.reviewer ? ` reviewer:${step.reviewer}` : '';
3065
3460
  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}`);
3461
+ // gate and human_approval are equivalent; normalise display
3462
+ const displayType = step.step_type || step.stepType || 'human';
3463
+ console.log(` Step: ${step.name} [${step.role || 'unassigned'}] (${displayType})${reviewer}${cycles}`);
3067
3464
  if (step.outputs?.length) {
3068
3465
  console.log(' Outputs:');
3069
3466
  for (const o of step.outputs) {
@@ -3125,9 +3522,101 @@ templates
3125
3522
  if (ac.repos)
3126
3523
  console.log(` repos: ${JSON.stringify(ac.repos)}`);
3127
3524
  }
3525
+ // sections (agent instructions) — truncated at 200 chars unless --show-sections
3526
+ if (step.sections) {
3527
+ const full = step.sections;
3528
+ if (opts.showSections || full.length <= 200) {
3529
+ console.log(` Sections:\n${full.split('\n').map((l) => ` ${l}`).join('\n')}`);
3530
+ }
3531
+ else {
3532
+ console.log(` Sections: ${full.slice(0, 200)}... (${full.length} chars total)`);
3533
+ }
3534
+ }
3535
+ // verification block
3536
+ if (step.verification) {
3537
+ console.log(` Verification: ${JSON.stringify(step.verification)}`);
3538
+ }
3539
+ // uat block
3540
+ if (step.uat) {
3541
+ console.log(` UAT: ${JSON.stringify(step.uat)}`);
3542
+ }
3543
+ }
3544
+ }
3545
+ }
3546
+ }
3547
+ catch (err) {
3548
+ console.error(chalk.red(err.message));
3549
+ process.exit(1);
3550
+ }
3551
+ });
3552
+ templates
3553
+ .command('export')
3554
+ .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.')
3555
+ .argument('<id>', 'Template ID (use "tpl list" to find IDs)')
3556
+ .option('--out <file>', 'Write output to file instead of stdout')
3557
+ .action(async (id, opts) => {
3558
+ try {
3559
+ const data = await api('GET', `/api/pipeline-templates/${id}`);
3560
+ const t = data.template;
3561
+ const json = JSON.stringify(t.definition, null, 2);
3562
+ if (opts.out) {
3563
+ const outPath = resolve(opts.out);
3564
+ writeFileSync(outPath, json, 'utf-8');
3565
+ console.log(chalk.green(`Definition written to ${outPath}`));
3566
+ }
3567
+ else {
3568
+ console.log(json);
3569
+ }
3570
+ }
3571
+ catch (err) {
3572
+ console.error(chalk.red(err.message));
3573
+ process.exit(1);
3574
+ }
3575
+ });
3576
+ templates
3577
+ .command('clone')
3578
+ .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.')
3579
+ .argument('<sourceId>', 'Source template ID to clone from')
3580
+ .requiredOption('--name <name>', 'Name for the new draft template')
3581
+ .option('--description <desc>', 'Description for the new draft template')
3582
+ .action(async (sourceId, opts) => {
3583
+ try {
3584
+ // Fetch source definition
3585
+ const data = await api('GET', `/api/pipeline-templates/${sourceId}`);
3586
+ const src = data.template;
3587
+ // Deep-clone and remap all step/phase UUIDs so no shared IDs exist
3588
+ const srcDef = JSON.parse(JSON.stringify(src.definition));
3589
+ // Build a UUID remap table: old id → new id for phases and steps
3590
+ const idMap = new Map();
3591
+ for (const phase of srcDef.phases || []) {
3592
+ idMap.set(phase.id, crypto.randomUUID());
3593
+ for (const step of phase.steps || []) {
3594
+ idMap.set(step.id, crypto.randomUUID());
3595
+ }
3596
+ }
3597
+ // Apply remapping throughout the definition
3598
+ for (const phase of srcDef.phases || []) {
3599
+ phase.id = idMap.get(phase.id);
3600
+ for (const step of phase.steps || []) {
3601
+ step.id = idMap.get(step.id);
3602
+ // Remap step input source_step_id references
3603
+ for (const inp of step.inputs || []) {
3604
+ if (inp.source_step_id && idMap.has(inp.source_step_id)) {
3605
+ inp.source_step_id = idMap.get(inp.source_step_id);
3606
+ }
3128
3607
  }
3129
3608
  }
3130
3609
  }
3610
+ // Create new draft template
3611
+ const body = { name: opts.name, definition: srcDef };
3612
+ if (opts.description)
3613
+ body.description = opts.description;
3614
+ const created = await api('POST', '/api/pipeline-templates', body);
3615
+ const newTpl = created.template;
3616
+ console.log(chalk.green(`Cloned: ${newTpl.name}`));
3617
+ console.log(` ID: ${newTpl.id}`);
3618
+ console.log(` Source: ${src.name} (${sourceId})`);
3619
+ console.log(chalk.dim(` Run "fazemos tpl validate ${newTpl.id}" to verify the clone.`));
3131
3620
  }
3132
3621
  catch (err) {
3133
3622
  console.error(chalk.red(err.message));
@@ -3432,7 +3921,7 @@ templates
3432
3921
  .argument('<templateId>', 'Template ID')
3433
3922
  .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl phases", "tpl show", or "tpl add-phase" output)')
3434
3923
  .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')
3924
+ .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
3925
  .option('--role <role>', 'Role or agent name (e.g., "kate", "marco", "dev-team")')
3437
3926
  .option('--description <desc>', 'Step description')
3438
3927
  .option('--reviewer <reviewer>', 'Reviewer role for review steps')
@@ -3449,6 +3938,8 @@ templates
3449
3938
  .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
3939
  .option('--stream-silence-abort-ms <ms>', 'Stream silence abort threshold in milliseconds (30000-1800000)', parseStreamSilenceAbortMs)
3451
3940
  .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)
3941
+ .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.')
3942
+ .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
3943
  .action(async (templateId, opts) => {
3453
3944
  try {
3454
3945
  if (!VALID_STEP_TYPES.includes(opts.type)) {
@@ -3512,6 +4003,16 @@ templates
3512
4003
  step.stream_silence_abort_ms = opts.streamSilenceAbortMs;
3513
4004
  if (opts.evalMaxTurns !== undefined)
3514
4005
  step.eval_max_turns = opts.evalMaxTurns;
4006
+ if (opts.verification != null) {
4007
+ const v = parseJsonOrFile(opts.verification, '--verification');
4008
+ if (v !== null)
4009
+ step.verification = v;
4010
+ }
4011
+ if (opts.uat != null) {
4012
+ const u = parseJsonOrFile(opts.uat, '--uat');
4013
+ if (u !== null)
4014
+ step.uat = u;
4015
+ }
3515
4016
  // Positioning: --after or --sort-order
3516
4017
  if (opts.after && opts.sortOrder !== undefined) {
3517
4018
  console.error(chalk.red('Cannot use both --after and --sort-order'));
@@ -3598,7 +4099,7 @@ templates
3598
4099
  .argument('<templateId>', 'Template ID')
3599
4100
  .requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
3600
4101
  .option('--name <name>', 'New step name (must be unique within the phase)')
3601
- .option('--type <type>', 'Step type: human, agent, script, gate')
4102
+ .option('--type <type>', 'Step type: human, agent, script, gate/human_approval (gate and human_approval are equivalent aliases for approval checkpoint steps)')
3602
4103
  .option('--role <role>', 'Role or agent name')
3603
4104
  .option('--description <desc>', 'Description')
3604
4105
  .option('--reviewer <reviewer>', 'Reviewer role (empty string to clear)')
@@ -3614,6 +4115,8 @@ templates
3614
4115
  .option('--stream-silence-abort-ms <ms>', 'Stream silence abort threshold in milliseconds (30000-1800000)', parseStreamSilenceAbortMs)
3615
4116
  .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
4117
  .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.')
4118
+ .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}}",...}\')')
4119
+ .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
4120
  .action(async (templateId, opts) => {
3618
4121
  try {
3619
4122
  if (opts.evalMaxTurns !== undefined && opts.clearEvalMaxTurns) {
@@ -3625,7 +4128,8 @@ templates
3625
4128
  || opts.reviewer != null || opts.maxReviewCycles != null || opts.parallelGroup != null
3626
4129
  || opts.image || opts.command || opts.timeout !== undefined || opts.workingDir || opts.env
3627
4130
  || opts.agentConfig || opts.sections != null || opts.streamSilenceAbortMs !== undefined
3628
- || opts.evalMaxTurns !== undefined || opts.clearEvalMaxTurns;
4131
+ || opts.evalMaxTurns !== undefined || opts.clearEvalMaxTurns
4132
+ || opts.verification != null || opts.uat != null;
3629
4133
  if (!hasUpdate) {
3630
4134
  console.error(chalk.red('Provide at least one field to update'));
3631
4135
  process.exit(1);
@@ -3705,6 +4209,26 @@ templates
3705
4209
  else if (opts.clearEvalMaxTurns) {
3706
4210
  delete step.eval_max_turns;
3707
4211
  }
4212
+ // verification block — empty string clears, any other value replaces
4213
+ if (opts.verification != null) {
4214
+ const v = parseJsonOrFile(opts.verification, '--verification');
4215
+ if (v === null) {
4216
+ delete step.verification;
4217
+ }
4218
+ else {
4219
+ step.verification = v;
4220
+ }
4221
+ }
4222
+ // uat block — empty string clears, any other value replaces
4223
+ if (opts.uat != null) {
4224
+ const u = parseJsonOrFile(opts.uat, '--uat');
4225
+ if (u === null) {
4226
+ delete step.uat;
4227
+ }
4228
+ else {
4229
+ step.uat = u;
4230
+ }
4231
+ }
3708
4232
  await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
3709
4233
  console.log(chalk.green(`Updated step: ${step.name}`));
3710
4234
  }
@@ -5828,25 +6352,127 @@ async function resolveAgentId(nameOrId) {
5828
6352
  }
5829
6353
  agentsCmd
5830
6354
  .command('register')
5831
- .description('Register an agent')
6355
+ .description('Register an agent and optionally provision an API credential (fzm_ key) in the same call. ' +
6356
+ 'Credential provisioning is on by default (pass --no-provision-credential to opt out). ' +
6357
+ 'Owner/admin only when provisioning credentials; agent identities are rejected.')
5832
6358
  .requiredOption('-n, --name <name>', 'Agent display name')
5833
6359
  .option('-r, --roles <roles>', 'Comma-separated roles', (v) => v.split(','))
5834
6360
  .option('--model <model>', 'Model (e.g., opus, sonnet, system)', 'sonnet')
6361
+ .option('--project <slug>', 'Project slug to scope the credential to (defaults to active project). Required when provisioning a credential.')
6362
+ .option('--no-provision-credential', 'Skip credential provisioning. Default: credential IS provisioned (pass this flag to opt out).')
6363
+ .option('--force-rotate', 'Rotate an existing credential — revoke the old key and mint a new one in the same transaction.')
6364
+ .option('--json', 'Emit JSON instead of human-readable output')
6365
+ // NOTE: --no-provision-credential is the sole option definition; Commander v12 automatically
6366
+ // creates --provision-credential as the positive form and defaults provisionCredential to true.
5835
6367
  .action(async (opts) => {
5836
6368
  try {
6369
+ // F42 — CLI defaults --provision-credential to true (Sage UX lock).
6370
+ // opts.provisionCredential is `true` unless --no-provision-credential was passed (sets it to false).
6371
+ const provisionCredential = opts.provisionCredential !== false;
6372
+ // Resolve credentialProjectId from --project or active project when provisioning.
6373
+ let credentialProjectId;
6374
+ if (provisionCredential) {
6375
+ if (opts.project) {
6376
+ // Resolve slug → id via cache; refresh once on miss.
6377
+ const orgId = getActiveOrgId();
6378
+ if (orgId) {
6379
+ let proj = findProjectBySlug(orgId, opts.project);
6380
+ if (!proj) {
6381
+ await refreshAuthMeCache();
6382
+ proj = findProjectBySlug(orgId, opts.project);
6383
+ }
6384
+ if (proj) {
6385
+ credentialProjectId = proj.id;
6386
+ }
6387
+ else {
6388
+ const msg = `Unknown project: ${opts.project}`;
6389
+ if (opts.json) {
6390
+ console.log(JSON.stringify({ error: { code: 'UNKNOWN_PROJECT', message: msg } }));
6391
+ }
6392
+ else {
6393
+ console.error(chalk.red(msg));
6394
+ console.log(chalk.gray('Run: fazemos projects list'));
6395
+ }
6396
+ process.exit(1);
6397
+ return; // guard: process.exit is mocked in tests
6398
+ }
6399
+ }
6400
+ }
6401
+ else {
6402
+ credentialProjectId = getActiveProjectId() ?? undefined;
6403
+ }
6404
+ if (!credentialProjectId) {
6405
+ const msg = 'No active Project. Pass --project <slug> or fazemos projects switch <slug>.';
6406
+ if (opts.json) {
6407
+ console.log(JSON.stringify({ error: { code: 'NO_ACTIVE_PROJECT', message: msg } }));
6408
+ }
6409
+ else {
6410
+ console.error(chalk.red(msg));
6411
+ }
6412
+ process.exit(1);
6413
+ return; // guard: process.exit is mocked in tests
6414
+ }
6415
+ }
6416
+ const agentEntry = {
6417
+ displayName: opts.name,
6418
+ roles: opts.roles || [opts.name],
6419
+ config: { model: opts.model },
6420
+ provisionCredential,
6421
+ };
6422
+ if (provisionCredential) {
6423
+ agentEntry.credentialProjectId = credentialProjectId;
6424
+ if (opts.forceRotate)
6425
+ agentEntry.forceRotate = true;
6426
+ }
5837
6427
  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
- }],
6428
+ agents: [agentEntry],
5843
6429
  });
6430
+ if (opts.json) {
6431
+ console.log(JSON.stringify({ agents: data.agents }));
6432
+ return;
6433
+ }
5844
6434
  for (const a of data.agents) {
5845
6435
  const icon = a.status === 'created' ? chalk.green('✓') : a.status === 'updated' ? chalk.yellow('↻') : '○';
5846
6436
  console.log(` ${icon} ${a.display_name} (${a.status}) — ${a.member_id}`);
6437
+ // F42 — credential block (matches `fazemos api-keys create` rendering per Sage UX)
6438
+ if (a.credential) {
6439
+ const cred = a.credential;
6440
+ if (cred.status === 'minted' || cred.status === 'rotated') {
6441
+ console.log('');
6442
+ console.log(chalk.green(` API credential ${cred.status}: ${opts.name}-key`));
6443
+ console.log(` Key: ${chalk.yellow(cred.rawKey)}`);
6444
+ console.log(` ID: ${cred.apiKeyId}`);
6445
+ console.log('');
6446
+ console.log(chalk.red(' ⚠ Save this key now — it will not be shown again.'));
6447
+ }
6448
+ else if (cred.status === 'exists') {
6449
+ console.log(chalk.gray(` (credential already provisioned — use --force-rotate to rotate)`));
6450
+ }
6451
+ }
6452
+ if (a.auditLogId) {
6453
+ console.log(chalk.gray(` Audit: ${a.auditLogId}`));
6454
+ }
5847
6455
  }
5848
6456
  }
5849
6457
  catch (err) {
6458
+ if (opts.json) {
6459
+ console.log(JSON.stringify({ error: { code: err.code ?? 'ERROR', message: err.message } }));
6460
+ process.exit(1);
6461
+ }
6462
+ if (err instanceof ApiError) {
6463
+ if (err.code === 'FORBIDDEN_ROLE') {
6464
+ console.error(chalk.red('Access denied. Owner or admin role required to provision credentials. Agent identities are not permitted.'));
6465
+ process.exit(1);
6466
+ }
6467
+ if (err.code === 'CREDENTIAL_PROJECT_REQUIRED') {
6468
+ console.error(chalk.red('credentialProjectId is required when provisionCredential=true. Pass --project <slug>.'));
6469
+ process.exit(1);
6470
+ }
6471
+ if (err.code === 'CREDENTIAL_PROVISION_FAILED') {
6472
+ console.error(chalk.red('Credential provisioning failed — transaction rolled back. Try again or contact your admin.'));
6473
+ process.exit(1);
6474
+ }
6475
+ }
5850
6476
  console.error(chalk.red(err.message));
5851
6477
  process.exit(1);
5852
6478
  }
@@ -8809,7 +9435,13 @@ registerGovernanceCommands(program);
8809
9435
  // Earlier `import.meta.url === file://${process.argv[1]}` check was wrong: the
8810
9436
  // bin shim's argv[1] is the shim path, not the dist/index.js path, so the guard
8811
9437
  // returned false and silently skipped parse.
8812
- if (!process.env.VITEST)
8813
- program.parse();
9438
+ // parseAsync (not parse) so the async preAction hook that resolves the
9439
+ // directory-scoped org/project context can be awaited before any action runs.
9440
+ if (!process.env.VITEST) {
9441
+ program.parseAsync().catch((err) => {
9442
+ console.error(chalk.red(err?.message ?? String(err)));
9443
+ process.exit(1);
9444
+ });
9445
+ }
8814
9446
  export { program };
8815
9447
  //# sourceMappingURL=index.js.map