@formigio/fazemos-cli 0.10.13 → 0.10.15

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
@@ -22,6 +22,56 @@ function parseNumber(val) {
22
22
  throw new Error(`Invalid number: ${val}`);
23
23
  return n;
24
24
  }
25
+ /**
26
+ * OPS-2 — Manual Interventions KPI cell formatter.
27
+ *
28
+ * Single source of CLI truth for the tri-state coloring rule encoded in
29
+ * rul_intervention_chip_thresholds_v1. Mirrors the web InterventionChip
30
+ * thresholds so the CLI MI column on `pl list`, the header line on
31
+ * `pl show`, and the `ops interventions` table all render the same way:
32
+ *
33
+ * null → `—` (no color) — pre-migration row or unknown state
34
+ * 0 → green — clean run
35
+ * 1-3 → amber — some operator help
36
+ * ≥4 → red — carried
37
+ * >999 → `999+` — cap per rul_intervention_chip_count_cap_999plus
38
+ *
39
+ * Width: 5 chars (right-padded inside the chalk color call so colors don't
40
+ * count against the visible width). Callers can wrap with additional text
41
+ * (e.g. `MI:${cell}`) without breaking alignment.
42
+ */
43
+ function formatManualInterventionCell(count) {
44
+ if (count === null || count === undefined) {
45
+ return chalk.gray('—'.padEnd(5));
46
+ }
47
+ const display = count > 999 ? '999+' : String(count);
48
+ const padded = display.padEnd(5);
49
+ if (count === 0)
50
+ return chalk.green(padded);
51
+ if (count <= 3)
52
+ return chalk.yellow(padded);
53
+ return chalk.red(padded);
54
+ }
55
+ /**
56
+ * OPS-2 — parse a duration string ('24h', '7d', '30d', '90d', etc.) into
57
+ * an ISO-8601 datetime `since` value for the GET /api/ops/manual-interventions
58
+ * `since` query param.
59
+ *
60
+ * Accepted units: `h` (hours), `d` (days), `w` (weeks). Unrecognised values
61
+ * throw and surface to the operator via the standard error path.
62
+ */
63
+ function parseSinceDuration(val) {
64
+ const m = /^(\d+)([hdw])$/.exec(val.trim());
65
+ if (!m) {
66
+ throw new Error(`Invalid --since "${val}". Expected forms like 24h, 7d, 30d, 4w.`);
67
+ }
68
+ const n = Number(m[1]);
69
+ const unit = m[2];
70
+ const ms = unit === 'h' ? n * 3_600_000
71
+ : unit === 'd' ? n * 86_400_000
72
+ : n * 604_800_000;
73
+ return new Date(Date.now() - ms).toISOString();
74
+ }
25
75
  function parseStreamSilenceAbortMs(val) {
26
76
  const n = Number(val);
27
77
  if (!Number.isInteger(n) || n < 30000 || n > 1800000) {
@@ -1340,27 +1390,34 @@ const connections = program.command('connections').alias('conn').description('Gi
1340
1390
  */
1341
1391
  connections
1342
1392
  .command('list')
1343
- .description('List GitHub Connections in the active organization')
1393
+ .description('List VCS Connections (GitHub + BitBucket) in the active organization')
1344
1394
  .option('-s, --status <status>', 'Filter: active (default), all, pending, suspended, revoked, uninstalled', 'active')
1345
1395
  .action(async (opts) => {
1346
1396
  try {
1347
1397
  const orgId = requireActiveOrgOrExit();
1348
- const data = await api('GET', `/api/organizations/${orgId}/github/connections?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
1349
- const list = data.connections ?? [];
1398
+ // F22 call the provider-agnostic /vcs/connections endpoint.
1399
+ // The legacy /github/connections alias still works during the
1400
+ // dual-write phase, but `vcs/` returns BOTH providers' rows so
1401
+ // the user sees their full Org Connection list.
1402
+ const data = await api('GET', `/api/organizations/${orgId}/vcs/connections?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
1403
+ const list = (data.connections ?? []).map(normalizeConnection);
1350
1404
  if (list.length === 0) {
1351
1405
  const orgName = findOrgById(orgId)?.name ?? orgId;
1352
- console.log(chalk.yellow(`No GitHub connections in ${orgName}.`));
1406
+ console.log(chalk.yellow(`No code platform connections in ${orgName}.`));
1353
1407
  console.log(chalk.gray('Add one with: fazemos connections install'));
1354
1408
  return;
1355
1409
  }
1356
1410
  const orgName = findOrgById(orgId)?.name ?? orgId;
1357
- console.log(chalk.cyan(`GitHub connections in ${orgName}:`));
1411
+ console.log(chalk.cyan(`Code platform connections in ${orgName}:`));
1358
1412
  console.log('');
1359
1413
  for (const c of list) {
1360
1414
  const statusColor = pickStatusColor(c.status);
1361
1415
  const projects = c.projectCount == null ? '—' : `${c.projectCount} ${c.projectCount === 1 ? 'project' : 'projects'}`;
1362
- const login = c.githubAccountLogin ?? chalk.gray('—');
1363
- console.log(` ${chalk.cyan(c.name)} ${chalk.gray(login)} ${statusColor(c.status)} ${chalk.gray(projects)}`);
1416
+ const login = c.accountLogin ?? chalk.gray('—');
1417
+ const providerLabel = formatProviderLabel(c.provider);
1418
+ // F22 §8.3 item 1: provider column. Renders as a prefix tag so
1419
+ // mixed-provider lists are scannable at a glance.
1420
+ console.log(` ${chalk.magenta(providerLabel)} ${chalk.cyan(c.name)} ${chalk.gray(login)} ${statusColor(c.status)} ${chalk.gray(projects)}`);
1364
1421
  console.log(chalk.gray(` ID: ${c.id}`));
1365
1422
  }
1366
1423
  }
@@ -1375,40 +1432,56 @@ connections
1375
1432
  */
1376
1433
  connections
1377
1434
  .command('show')
1378
- .description('Show a GitHub Connection in detail')
1435
+ .description('Show a VCS Connection in detail')
1379
1436
  .argument('<id>', 'Connection ID')
1380
1437
  .action(async (id) => {
1381
1438
  try {
1382
1439
  const orgId = requireActiveOrgOrExit();
1383
- const data = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1384
- const c = data.connection;
1385
- console.log(chalk.cyan(c.name));
1440
+ // F22 provider-agnostic detail endpoint. The serialized shape
1441
+ // includes `provider`, `account_login`, `supports_contents_api`
1442
+ // and (for GitHub) `installation_id` / (BitBucket) `workspace_uuid`.
1443
+ const data = await api('GET', `/api/organizations/${orgId}/vcs/connections/${id}`, undefined, { noProjectHeader: true });
1444
+ const raw = data.connection ?? data;
1445
+ const c = normalizeConnection(raw);
1446
+ console.log(chalk.cyan(c.name ?? '(unnamed connection)'));
1386
1447
  console.log(` ID: ${c.id}`);
1387
- console.log(` GitHub: ${c.githubAccountLogin ?? chalk.gray('(pending)')}`);
1388
- if (c.githubAccountType)
1389
- console.log(` Account type: ${c.githubAccountType}`);
1448
+ // F22 §8.3 item 2 provider line above account info.
1449
+ console.log(` Provider: ${formatProviderLabel(c.provider)}`);
1450
+ console.log(` Account: ${c.accountLogin ?? chalk.gray('— (pending)')}`);
1451
+ if (c.accountType)
1452
+ console.log(` Account type: ${c.accountType}`);
1390
1453
  const statusColor = pickStatusColor(c.status);
1391
1454
  console.log(` Status: ${statusColor(c.status)}`);
1392
- console.log(` Installed: ${c.installedAt ? new Date(c.installedAt).toLocaleString() : '(unknown)'}`);
1455
+ if (c.installedAt) {
1456
+ console.log(` Installed: ${new Date(c.installedAt).toLocaleString()}`);
1457
+ }
1393
1458
  if (c.lastHealthCheckAt) {
1394
1459
  console.log(` Last checked: ${new Date(c.lastHealthCheckAt).toLocaleString()}`);
1395
1460
  }
1396
1461
  if (c.lastUsedAt) {
1397
1462
  console.log(` Last used: ${new Date(c.lastUsedAt).toLocaleString()}`);
1398
1463
  }
1399
- if (c.repositorySelection === 'all') {
1400
- console.log(` Repos: All repositories${c.githubAccountLogin ? ` in ${c.githubAccountLogin}` : ''}`);
1464
+ // F22 §6.4 supports_contents_api is the canonical signal for
1465
+ // "can this Connection back F18 docs surfaces?" Surface it so
1466
+ // users understand why docs may be unavailable on BitBucket.
1467
+ if (typeof c.supportsContentsApi === 'boolean') {
1468
+ const docs = c.supportsContentsApi ? 'yes' : chalk.yellow('no');
1469
+ console.log(` Docs API: ${docs}`);
1401
1470
  }
1402
- else if (Array.isArray(c.repositories)) {
1403
- console.log(` Repos: ${c.repositories.length}${c.repositoriesTruncated ? ' (showing 100)' : ''}`);
1404
- for (const r of c.repositories) {
1405
- console.log(chalk.gray(` · ${r.fullName}`));
1471
+ if (raw.repositorySelection === 'all') {
1472
+ console.log(` Repos: All repositories${c.accountLogin ? ` in ${c.accountLogin}` : ''}`);
1473
+ }
1474
+ else if (Array.isArray(raw.repositories)) {
1475
+ console.log(` Repos: ${raw.repositories.length}${raw.repositoriesTruncated ? ' (showing 100)' : ''}`);
1476
+ for (const r of raw.repositories) {
1477
+ console.log(chalk.gray(` · ${r.fullName ?? r.full_name ?? r.name}`));
1406
1478
  }
1407
1479
  }
1408
- if (Array.isArray(c.boundProjects) && c.boundProjects.length > 0) {
1480
+ const boundProjects = raw.boundProjects ?? raw.bound_projects ?? [];
1481
+ if (Array.isArray(boundProjects) && boundProjects.length > 0) {
1409
1482
  console.log('');
1410
1483
  console.log(chalk.cyan('Projects using this connection:'));
1411
- for (const p of c.boundProjects) {
1484
+ for (const p of boundProjects) {
1412
1485
  console.log(` · ${p.name} (${p.slug})`);
1413
1486
  }
1414
1487
  }
@@ -1429,12 +1502,55 @@ connections
1429
1502
  */
1430
1503
  connections
1431
1504
  .command('install')
1432
- .description('Add a GitHub Connection. Prints an install URL and waits for a confirmation code.')
1433
- .action(async () => {
1505
+ .description('Add a VCS Connection (GitHub or BitBucket). Prints an install URL and (for GitHub) waits for a confirmation code.')
1506
+ .option('--provider <provider>', 'VCS provider: github | bitbucket')
1507
+ .action(async (opts) => {
1434
1508
  try {
1435
1509
  const orgId = requireActiveOrgOrExit();
1436
1510
  const orgName = findOrgById(orgId)?.name ?? orgId;
1437
- const mintData = await api('POST', `/api/organizations/${orgId}/github/connections/install-url`, { source: 'cli', returnTo: null }, { noProjectHeader: true });
1511
+ // F22 §8.3 item 3 provider selection matrix.
1512
+ // 1) `--provider github|bitbucket` supplied: use directly.
1513
+ // 2) No flag: prompt the user interactively (provider-picker).
1514
+ // The CLI cannot probe server-side provider configuration, so the
1515
+ // "exactly one provider configured" auto-select branch of the spec
1516
+ // is implemented as "user always confirms" — narrower than the
1517
+ // spec wording but strictly safer (no silent provider choice).
1518
+ let provider = opts.provider?.toLowerCase();
1519
+ if (!provider) {
1520
+ provider = await promptProviderChoice();
1521
+ }
1522
+ if (provider !== 'github' && provider !== 'bitbucket') {
1523
+ console.error(chalk.red(`Invalid --provider: ${provider}. Use 'github' or 'bitbucket'.`));
1524
+ process.exit(1);
1525
+ }
1526
+ // F22 prefers the new provider-agnostic /vcs/connections route, which
1527
+ // expects `provider` in the body. The github-only endpoint is kept
1528
+ // alive but not used here so all new connections flow through one path.
1529
+ const mintData = await api('POST', `/api/organizations/${orgId}/vcs/connections/install-url`, { provider, source: 'cli', returnTo: null }, { noProjectHeader: true });
1530
+ // F22 §8.3 item 8 — BitBucket install is browser-only in v1 (no CLI
1531
+ // confirmation-code exchange counterpart). Print the literal copy
1532
+ // block specified in the spec verbatim, then exit successfully.
1533
+ // The user completes consent in the browser and the /api/bitbucket/
1534
+ // oauth/callback handler finishes the Connection server-side.
1535
+ //
1536
+ // Dex nit N4 — output literal MUST match the spec block exactly so
1537
+ // the acceptance test can assert it.
1538
+ if (provider === 'bitbucket') {
1539
+ console.log('');
1540
+ console.log('BitBucket Connection install — browser required');
1541
+ console.log('');
1542
+ console.log('Open this URL in your browser to authorize Fazemos:');
1543
+ console.log('');
1544
+ console.log(` ${mintData.url}`);
1545
+ console.log('');
1546
+ console.log('After you authorize, BitBucket will redirect to Fazemos and complete the Connection.');
1547
+ console.log('You can then list your connections with:');
1548
+ console.log('');
1549
+ console.log(' fazemos connections list');
1550
+ console.log('');
1551
+ console.log('This URL expires in 10 minutes.');
1552
+ return;
1553
+ }
1438
1554
  console.log('');
1439
1555
  console.log(`To add a GitHub connection to ${chalk.cyan(orgName)}:`);
1440
1556
  console.log('');
@@ -1523,18 +1639,20 @@ connections
1523
1639
  connections
1524
1640
  .command('revoke')
1525
1641
  .alias('disconnect')
1526
- .description('Disconnect a GitHub Connection (Fazemos-side; does not uninstall the App from GitHub).')
1642
+ .description('Disconnect a VCS Connection (Fazemos-side; does not uninstall the App from GitHub / revoke the OAuth grant on BitBucket).')
1527
1643
  .argument('<id>', 'Connection ID')
1528
1644
  .option('-f, --force', 'Skip the confirmation prompt', false)
1529
1645
  .action(async (id, opts) => {
1530
1646
  try {
1531
1647
  const orgId = requireActiveOrgOrExit();
1532
1648
  // Fetch the Connection so we can show what we're about to revoke
1533
- // and how many projects it affects (Sage §8.3 confirmation copy).
1534
- let connection;
1649
+ // and how many projects it affects (UX §8.3 confirmation copy).
1650
+ // F22 — use the provider-agnostic /vcs/ endpoint so both provider
1651
+ // rows are reachable.
1652
+ let raw;
1535
1653
  try {
1536
- const detail = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1537
- connection = detail.connection;
1654
+ const detail = await api('GET', `/api/organizations/${orgId}/vcs/connections/${id}`, undefined, { noProjectHeader: true });
1655
+ raw = detail.connection ?? detail;
1538
1656
  }
1539
1657
  catch (err) {
1540
1658
  if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
@@ -1543,15 +1661,17 @@ connections
1543
1661
  }
1544
1662
  throw err;
1545
1663
  }
1546
- const boundProjects = connection.boundProjects ?? [];
1664
+ const connection = normalizeConnection(raw);
1665
+ const boundProjects = raw.boundProjects ?? raw.bound_projects ?? [];
1666
+ const providerLabelStr = formatProviderLabel(connection.provider);
1547
1667
  if (!opts.force) {
1548
1668
  console.log('');
1549
- console.log(`Disconnect ${chalk.cyan(connection.name)}?`);
1669
+ console.log(`Disconnect ${chalk.magenta(providerLabelStr)} ${chalk.cyan(connection.name ?? id)}?`);
1550
1670
  console.log('');
1551
1671
  if (boundProjects.length > 0) {
1552
1672
  const projectList = boundProjects.map(p => p.name).join(', ');
1553
1673
  console.log(` This connection is used by ${boundProjects.length} ${boundProjects.length === 1 ? 'project' : 'projects'}: ${projectList}.`);
1554
- console.log(' Pipelines in those projects will fail on GitHub steps until');
1674
+ console.log(` Pipelines in those projects will fail on ${providerLabelStr} steps until`);
1555
1675
  console.log(' they are bound to a different connection.');
1556
1676
  console.log('');
1557
1677
  }
@@ -1559,7 +1679,13 @@ connections
1559
1679
  console.log(' No projects are using this connection.');
1560
1680
  console.log('');
1561
1681
  }
1562
- console.log(chalk.gray(' This does not uninstall the Fazemos App from GitHub.'));
1682
+ // Provider-aware caveat: GitHub App vs BitBucket OAuth grant.
1683
+ if (connection.provider === 'bitbucket') {
1684
+ console.log(chalk.gray(' This does not revoke the OAuth grant on BitBucket.'));
1685
+ }
1686
+ else {
1687
+ console.log(chalk.gray(' This does not uninstall the Fazemos App from GitHub.'));
1688
+ }
1563
1689
  console.log('');
1564
1690
  const answer = await promptLine('Disconnect? [y/N]: ');
1565
1691
  if (!answer || !/^y/i.test(answer.trim())) {
@@ -1567,11 +1693,14 @@ connections
1567
1693
  return;
1568
1694
  }
1569
1695
  }
1570
- const data = await api('DELETE', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1571
- console.log(chalk.green(`Disconnected: ${connection.name}`));
1572
- const affected = data.affectedProjects ?? [];
1573
- if (affected.length > 0) {
1574
- console.log(chalk.gray(` ${affected.length} project${affected.length === 1 ? '' : 's'} unbound.`));
1696
+ const data = await api('DELETE', `/api/organizations/${orgId}/vcs/connections/${id}`, undefined, { noProjectHeader: true });
1697
+ console.log(chalk.green(`Disconnected: ${providerLabelStr} ${connection.name ?? id}`));
1698
+ // F22 response shape: { revoked, secrets_deleted, projects_unbound }.
1699
+ // F16 legacy shape: { affectedProjects: [...] }. Handle both during
1700
+ // the dual-write rollout.
1701
+ const unbound = data.projects_unbound ?? (data.affectedProjects ?? []).length ?? 0;
1702
+ if (unbound > 0) {
1703
+ console.log(chalk.gray(` ${unbound} project${unbound === 1 ? '' : 's'} unbound.`));
1575
1704
  }
1576
1705
  invalidateAuthMeCache();
1577
1706
  }
@@ -1586,19 +1715,23 @@ connections
1586
1715
  */
1587
1716
  connections
1588
1717
  .command('health-check')
1589
- .description('Verify a Connection is still healthy on GitHub')
1718
+ .description('Verify a Connection is still healthy on its code platform (GitHub or BitBucket)')
1590
1719
  .argument('<id>', 'Connection ID')
1591
1720
  .action(async (id) => {
1592
1721
  try {
1593
1722
  const orgId = requireActiveOrgOrExit();
1594
- const data = await api('POST', `/api/organizations/${orgId}/github/connections/${id}/health-check`, {}, { noProjectHeader: true });
1595
- const c = data.connection;
1723
+ // F22 provider-aware health-check route on the /vcs/ alias.
1724
+ // The handler dispatches to the right provider (GitHub installation
1725
+ // probe / BitBucket OAuth-refresh + workspace probe).
1726
+ const data = await api('POST', `/api/organizations/${orgId}/vcs/connections/${id}/health-check`, {}, { noProjectHeader: true });
1727
+ const c = normalizeConnection(data.connection ?? data);
1596
1728
  const statusColor = pickStatusColor(c.status);
1729
+ const label = `${formatProviderLabel(c.provider)} ${c.name ?? id}`;
1597
1730
  if (data.changed) {
1598
- console.log(chalk.yellow(`Status changed: ${c.name} → ${statusColor(c.status)}`));
1731
+ console.log(chalk.yellow(`Status changed: ${label} → ${statusColor(c.status)}`));
1599
1732
  }
1600
1733
  else {
1601
- console.log(chalk.green(`✓ ${c.name} — ${statusColor(c.status)}`));
1734
+ console.log(chalk.green(`✓ ${label} — ${statusColor(c.status)}`));
1602
1735
  }
1603
1736
  }
1604
1737
  catch (err) {
@@ -1610,6 +1743,53 @@ connections
1610
1743
  process.exit(1);
1611
1744
  }
1612
1745
  });
1746
+ export function normalizeConnection(raw) {
1747
+ if (!raw || typeof raw !== 'object') {
1748
+ return {
1749
+ id: '',
1750
+ provider: null,
1751
+ name: null,
1752
+ accountLogin: null,
1753
+ accountType: null,
1754
+ status: 'unknown',
1755
+ installedAt: null,
1756
+ lastHealthCheckAt: null,
1757
+ lastUsedAt: null,
1758
+ supportsContentsApi: null,
1759
+ projectCount: null,
1760
+ };
1761
+ }
1762
+ // Provider — F22 new field. Older /github/ rows always meant github;
1763
+ // default there for back-compat.
1764
+ const provider = raw.provider ?? (raw.githubAccountLogin !== undefined ? 'github' : null);
1765
+ return {
1766
+ id: String(raw.id ?? ''),
1767
+ provider,
1768
+ name: raw.name ?? null,
1769
+ accountLogin: raw.accountLogin ?? raw.account_login ?? raw.githubAccountLogin ?? null,
1770
+ accountType: raw.accountType ?? raw.account_type ?? raw.githubAccountType ?? null,
1771
+ status: raw.status ?? 'unknown',
1772
+ installedAt: raw.installedAt ?? raw.installed_at ?? raw.created_at ?? null,
1773
+ lastHealthCheckAt: raw.lastHealthCheckAt ?? raw.last_health_check_at ?? null,
1774
+ lastUsedAt: raw.lastUsedAt ?? raw.last_used_at ?? null,
1775
+ supportsContentsApi: typeof raw.supportsContentsApi === 'boolean'
1776
+ ? raw.supportsContentsApi
1777
+ : typeof raw.supports_contents_api === 'boolean'
1778
+ ? raw.supports_contents_api
1779
+ : null,
1780
+ projectCount: raw.projectCount ?? raw.project_count ?? null,
1781
+ };
1782
+ }
1783
+ /**
1784
+ * F22 — Human-friendly provider label used in CLI columns and lines.
1785
+ */
1786
+ export function formatProviderLabel(provider) {
1787
+ if (provider === 'github')
1788
+ return 'GitHub';
1789
+ if (provider === 'bitbucket')
1790
+ return 'BitBucket';
1791
+ return '—';
1792
+ }
1613
1793
  /**
1614
1794
  * Helper — color a status string per the Sage §2.2 taxonomy.
1615
1795
  */
@@ -1629,6 +1809,30 @@ function pickStatusColor(status) {
1629
1809
  return chalk.white;
1630
1810
  }
1631
1811
  }
1812
+ /**
1813
+ * F22 §8.3 item 3 — interactive provider picker. Used by
1814
+ * `fazemos connections install` when no `--provider` flag is supplied
1815
+ * so the user explicitly chooses GitHub or BitBucket before the URL
1816
+ * mint hits the API. Mirrors the web `ProviderPickerModal` choice
1817
+ * without the modal chrome.
1818
+ *
1819
+ * Returns 'github' or 'bitbucket'. Repeats on invalid input.
1820
+ */
1821
+ export async function promptProviderChoice() {
1822
+ console.log('');
1823
+ console.log('Which code platform do you want to connect?');
1824
+ console.log(' 1. GitHub');
1825
+ console.log(' 2. BitBucket');
1826
+ console.log('');
1827
+ while (true) {
1828
+ const ans = (await promptLine(' Choose [1=GitHub / 2=BitBucket]: ')).trim().toLowerCase();
1829
+ if (ans === '1' || ans === 'github' || ans === 'gh')
1830
+ return 'github';
1831
+ if (ans === '2' || ans === 'bitbucket' || ans === 'bb')
1832
+ return 'bitbucket';
1833
+ console.log(chalk.yellow(' Please answer 1 (GitHub) or 2 (BitBucket).'));
1834
+ }
1835
+ }
1632
1836
  /**
1633
1837
  * Read a single line of input from stdin. No fancy framing — `readline`
1634
1838
  * is sufficient for the install confirmation-code prompt and the
@@ -4000,7 +4204,18 @@ pipelines
4000
4204
  }
4001
4205
  for (const inst of data.instances) {
4002
4206
  const stepInfo = inst.total_steps ? ` — step ${inst.current_step ?? '?'}/${inst.total_steps}` : '';
4003
- console.log(` ${chalk.cyan(inst.name)} (${inst.status})${stepInfo}`);
4207
+ // OPS-2 — Manual Interventions KPI. The MI column renders inline next
4208
+ // to the instance status with the prescribed tri-state coloring:
4209
+ // null → `—` (no color; pre-migration row or unknown)
4210
+ // 0 → green
4211
+ // 1-3 → amber
4212
+ // ≥4 → red
4213
+ // Capped per rul_intervention_chip_count_cap_999plus: values > 999
4214
+ // collapse to `999+` while the underlying count remains intact in
4215
+ // the API response and JSON output for scripts.
4216
+ const miCell = formatManualInterventionCell(inst.manual_intervention_count);
4217
+ const envCell = inst.env_tag ? chalk.gray(` [${inst.env_tag}]`) : '';
4218
+ console.log(` ${chalk.cyan(inst.name)} (${inst.status}) MI:${miCell}${envCell}${stepInfo}`);
4004
4219
  console.log(` ID: ${inst.id}`);
4005
4220
  if (opts.expand && inst.steps?.length) {
4006
4221
  for (const s of inst.steps) {
@@ -4029,6 +4244,14 @@ pipelines
4029
4244
  console.log(` ID: ${inst.id}`);
4030
4245
  console.log(` Status: ${inst.status}`);
4031
4246
  console.log(` Template: ${inst.template_name || inst.template_id}`);
4247
+ // OPS-2 — Manual Interventions KPI header line. Same coloring rule as
4248
+ // `pl list` (null=—, 0=green, 1-3=amber, 4+=red). env_tag is surfaced
4249
+ // alongside so operators can read prod-vs-dev at a glance (the cycle
4250
+ // exit criterion is prod-only).
4251
+ console.log(` Manual Interventions: ${formatManualInterventionCell(inst.manual_intervention_count)}`);
4252
+ if (inst.env_tag) {
4253
+ console.log(` Env Tag: ${inst.env_tag}`);
4254
+ }
4032
4255
  if (inst.phases?.length) {
4033
4256
  for (const phase of inst.phases) {
4034
4257
  console.log(chalk.cyan(`\n Phase: ${phase.name}`));
@@ -6995,6 +7218,156 @@ ops
6995
7218
  process.exit(1);
6996
7219
  }
6997
7220
  });
7221
+ // ── OPS-2 — Manual Interventions KPI ─────────────────────────────────
7222
+ // Spec: specs/tech/platform/OPS-2-manual-intervention-kpi-tech-spec.md
7223
+ //
7224
+ // `fazemos ops interventions` has three operating modes:
7225
+ //
7226
+ // 1. bare — windowed summary table + current zero-intervention
7227
+ // streak. Calls GET /api/ops/manual-interventions/summary.
7228
+ // Prod-only by default (cycle exit criterion is prod);
7229
+ // --include-dev inverts the env_tag filter for parity
7230
+ // with the API.
7231
+ // 2. --pipeline — per-pipeline drill. Calls GET /api/pipeline-instances/:id
7232
+ // (response now carries manual_intervention_count +
7233
+ // env_tag) and surfaces the count + the env_tag fence so
7234
+ // operators can read the chip the same way the web does.
7235
+ // Forensic per-event detail lives behind `fazemos agents
7236
+ // events --type manual_intervention` (existing surface);
7237
+ // we print the hint so the operator doesn't have to
7238
+ // rediscover it.
7239
+ // 3. --since — windowed list. Calls GET /api/ops/manual-interventions
7240
+ // with the resolved `since` timestamp. Defaults to
7241
+ // include-dev (false) → prod-only-NO; the GET list
7242
+ // endpoint defaults exclude_dev=false (the broader view
7243
+ // is more useful when scanning recent activity). The
7244
+ // --exclude-dev flag tightens to prod-only.
7245
+ //
7246
+ // Admin/owner gating is enforced server-side (403 FORBIDDEN); the CLI
7247
+ // surfaces the API's error verbatim so non-admin operators see the
7248
+ // authoritative reason rather than a stale local guess.
7249
+ ops
7250
+ .command('interventions')
7251
+ .description('Manual-intervention KPI. Bare mode shows the windowed summary table + '
7252
+ + 'current zero-intervention streak (prod-only by default). '
7253
+ + '--pipeline <id> drills into one pipeline. '
7254
+ + '--since <duration> lists pipelines started in the window.')
7255
+ .option('-w, --window <window>', 'Summary window: 24h, 7d, 30d', '7d')
7256
+ .option('-p, --pipeline <id>', 'Drill into one pipeline_instance by ID')
7257
+ .option('--since <duration>', 'List mode: filter by started_at >= now - duration (e.g. 24h, 7d, 30d)')
7258
+ .option('-l, --limit <n>', 'List mode: max rows (default 50, max 200)', '50')
7259
+ .option('--include-dev', 'Include dev-tagged pipelines in the summary / list (default: exclude)', false)
7260
+ .option('--exclude-dev', 'Force exclude dev-tagged pipelines (default for summary; opt-in for list)', false)
7261
+ .option('--json', 'Print the raw API response as JSON (machine-readable)')
7262
+ .action(async (opts) => {
7263
+ try {
7264
+ // ── Mode 1: --pipeline <id> drill ────────────────────────────
7265
+ if (opts.pipeline) {
7266
+ const data = await api('GET', `/api/pipeline-instances/${opts.pipeline}`);
7267
+ if (opts.json) {
7268
+ console.log(JSON.stringify(data, null, 2));
7269
+ return;
7270
+ }
7271
+ const inst = data.instance;
7272
+ if (!inst) {
7273
+ console.error(chalk.red(`Pipeline ${opts.pipeline} not found`));
7274
+ process.exit(1);
7275
+ }
7276
+ console.log(chalk.cyan(`Manual interventions on ${inst.name}`));
7277
+ console.log(` ID: ${inst.id}`);
7278
+ console.log(` Status: ${inst.status}`);
7279
+ console.log(` Env Tag: ${inst.env_tag ?? chalk.gray('—')}`);
7280
+ console.log(` Count: ${formatManualInterventionCell(inst.manual_intervention_count)}`);
7281
+ if (inst.started_at) {
7282
+ console.log(` Started: ${new Date(inst.started_at).toLocaleString()}`);
7283
+ }
7284
+ if (inst.completed_at) {
7285
+ console.log(` Completed: ${new Date(inst.completed_at).toLocaleString()}`);
7286
+ }
7287
+ console.log('');
7288
+ console.log(chalk.gray(' Forensic per-event detail: query agent_events for the assignee/reviewer'));
7289
+ console.log(chalk.gray(' identities that drove each increment. Example:'));
7290
+ console.log(chalk.gray(` fazemos agents events <agent-id> --type manual_intervention`));
7291
+ return;
7292
+ }
7293
+ // ── Mode 2: --since <duration> windowed list ─────────────────
7294
+ if (opts.since) {
7295
+ const since = parseSinceDuration(opts.since);
7296
+ const params = [`since=${encodeURIComponent(since)}`];
7297
+ // For the list endpoint, the API default is exclude_dev=false (a
7298
+ // broader scan-recent-activity surface). --exclude-dev tightens
7299
+ // to prod-only. --include-dev is a no-op here (the default
7300
+ // already includes dev) but accepted so the flag is symmetric
7301
+ // across modes.
7302
+ if (opts.excludeDev)
7303
+ params.push('exclude_dev=true');
7304
+ const limitN = Math.max(1, Math.min(200, Number(opts.limit) || 50));
7305
+ params.push(`limit=${limitN}`);
7306
+ const qs = `?${params.join('&')}`;
7307
+ const data = await api('GET', `/api/ops/manual-interventions${qs}`);
7308
+ if (opts.json) {
7309
+ console.log(JSON.stringify(data, null, 2));
7310
+ return;
7311
+ }
7312
+ const rows = data.pipelines || [];
7313
+ if (!rows.length) {
7314
+ console.log(chalk.green(`No interventions in window since ${since}`));
7315
+ return;
7316
+ }
7317
+ const scope = opts.excludeDev ? 'prod-only' : 'all envs';
7318
+ console.log(chalk.cyan(`Manual interventions since ${since} (${scope}):`));
7319
+ console.log('');
7320
+ // Columns: MI | Env | Status | Started | Name (ID)
7321
+ for (const r of rows) {
7322
+ const mi = formatManualInterventionCell(r.manual_intervention_count);
7323
+ const env = (r.env_tag ?? '—').padEnd(4);
7324
+ const status = (r.status ?? '').padEnd(10);
7325
+ const started = r.started_at ? new Date(r.started_at).toLocaleString() : '';
7326
+ console.log(` MI:${mi} ${chalk.gray(env)} ${status} ${chalk.gray(started)}`);
7327
+ console.log(` ${chalk.cyan(r.name)} ${chalk.gray(r.pipeline_instance_id)}`);
7328
+ }
7329
+ return;
7330
+ }
7331
+ // ── Mode 3: bare summary ─────────────────────────────────────
7332
+ // The summary endpoint defaults exclude_dev=true (cycle exit
7333
+ // criterion is prod-only by spec). --include-dev inverts. The CLI
7334
+ // is explicit either way so log lines are unambiguous.
7335
+ const excludeDev = opts.includeDev ? false : true;
7336
+ const window = opts.window || '7d';
7337
+ const validWindows = ['24h', '7d', '30d'];
7338
+ if (!validWindows.includes(window)) {
7339
+ console.error(chalk.red(`Invalid --window "${window}". Must be one of: ${validWindows.join(', ')}`));
7340
+ process.exit(1);
7341
+ }
7342
+ const qs = `?window=${window}&exclude_dev=${excludeDev}`;
7343
+ const data = await api('GET', `/api/ops/manual-interventions/summary${qs}`);
7344
+ if (opts.json) {
7345
+ console.log(JSON.stringify(data, null, 2));
7346
+ return;
7347
+ }
7348
+ const scope = excludeDev ? 'prod-only' : 'all envs';
7349
+ console.log(chalk.cyan(`Manual interventions — ${data.window} (${scope})`));
7350
+ console.log('');
7351
+ console.log(` Total runs: ${data.total_runs}`);
7352
+ console.log(` Total interventions: ${data.total_interventions}`);
7353
+ console.log(` Avg per run: ${typeof data.avg_per_run === 'number' ? data.avg_per_run.toFixed(2) : data.avg_per_run}`);
7354
+ console.log(` p95 per run: ${data.p95_per_run}`);
7355
+ console.log(` Zero-intervention: ${data.zero_intervention_runs} of ${data.total_runs} runs`);
7356
+ // Streak gets the same green/amber/grey emphasis as the web chip:
7357
+ // ≥5 is a celebration moment per Sage's spec; 1-4 amber; 0 grey.
7358
+ const streak = data.zero_intervention_streak ?? 0;
7359
+ const streakDisplay = streak >= 5
7360
+ ? chalk.green(`${streak} 🎉`)
7361
+ : streak >= 1
7362
+ ? chalk.yellow(String(streak))
7363
+ : chalk.gray('0');
7364
+ console.log(` Current streak: ${streakDisplay}`);
7365
+ }
7366
+ catch (err) {
7367
+ console.error(chalk.red(err.message));
7368
+ process.exit(1);
7369
+ }
7370
+ });
6998
7371
  // ── F18 — Project-scoped docs surface ────────────────────────────────
6999
7372
  // Spec: specs/tech/platform/F18-project-scoped-docs-surface-tech-spec.md §8
7000
7373
  //