@formigio/fazemos-cli 0.10.14 → 0.10.16

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
@@ -10,6 +10,7 @@ import { isProjectConnectionUnavailable, renderProjectConnectionUnavailableCopy,
10
10
  import { loadYaml, summarize } from './yaml/load.js';
11
11
  import { printFindings, printJson } from './yaml/format.js';
12
12
  import { validateManifest } from './manifest/checks.js';
13
+ import { findLocalRegistry, resolveRole, buildInboxFile, writeInboxFile, buildNotificationPayload, gitCommitInboxFile, } from './dispatch.js';
13
14
  import { execSync } from 'child_process';
14
15
  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
15
16
  import { fileURLToPath } from 'url';
@@ -1390,27 +1391,34 @@ const connections = program.command('connections').alias('conn').description('Gi
1390
1391
  */
1391
1392
  connections
1392
1393
  .command('list')
1393
- .description('List GitHub Connections in the active organization')
1394
+ .description('List VCS Connections (GitHub + BitBucket) in the active organization')
1394
1395
  .option('-s, --status <status>', 'Filter: active (default), all, pending, suspended, revoked, uninstalled', 'active')
1395
1396
  .action(async (opts) => {
1396
1397
  try {
1397
1398
  const orgId = requireActiveOrgOrExit();
1398
- const data = await api('GET', `/api/organizations/${orgId}/github/connections?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
1399
- const list = data.connections ?? [];
1399
+ // F22 call the provider-agnostic /vcs/connections endpoint.
1400
+ // The legacy /github/connections alias still works during the
1401
+ // dual-write phase, but `vcs/` returns BOTH providers' rows so
1402
+ // the user sees their full Org Connection list.
1403
+ const data = await api('GET', `/api/organizations/${orgId}/vcs/connections?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
1404
+ const list = (data.connections ?? []).map(normalizeConnection);
1400
1405
  if (list.length === 0) {
1401
1406
  const orgName = findOrgById(orgId)?.name ?? orgId;
1402
- console.log(chalk.yellow(`No GitHub connections in ${orgName}.`));
1407
+ console.log(chalk.yellow(`No code platform connections in ${orgName}.`));
1403
1408
  console.log(chalk.gray('Add one with: fazemos connections install'));
1404
1409
  return;
1405
1410
  }
1406
1411
  const orgName = findOrgById(orgId)?.name ?? orgId;
1407
- console.log(chalk.cyan(`GitHub connections in ${orgName}:`));
1412
+ console.log(chalk.cyan(`Code platform connections in ${orgName}:`));
1408
1413
  console.log('');
1409
1414
  for (const c of list) {
1410
1415
  const statusColor = pickStatusColor(c.status);
1411
1416
  const projects = c.projectCount == null ? '—' : `${c.projectCount} ${c.projectCount === 1 ? 'project' : 'projects'}`;
1412
- const login = c.githubAccountLogin ?? chalk.gray('—');
1413
- console.log(` ${chalk.cyan(c.name)} ${chalk.gray(login)} ${statusColor(c.status)} ${chalk.gray(projects)}`);
1417
+ const login = c.accountLogin ?? chalk.gray('—');
1418
+ const providerLabel = formatProviderLabel(c.provider);
1419
+ // F22 §8.3 item 1: provider column. Renders as a prefix tag so
1420
+ // mixed-provider lists are scannable at a glance.
1421
+ console.log(` ${chalk.magenta(providerLabel)} ${chalk.cyan(c.name)} ${chalk.gray(login)} ${statusColor(c.status)} ${chalk.gray(projects)}`);
1414
1422
  console.log(chalk.gray(` ID: ${c.id}`));
1415
1423
  }
1416
1424
  }
@@ -1425,40 +1433,56 @@ connections
1425
1433
  */
1426
1434
  connections
1427
1435
  .command('show')
1428
- .description('Show a GitHub Connection in detail')
1436
+ .description('Show a VCS Connection in detail')
1429
1437
  .argument('<id>', 'Connection ID')
1430
1438
  .action(async (id) => {
1431
1439
  try {
1432
1440
  const orgId = requireActiveOrgOrExit();
1433
- const data = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1434
- const c = data.connection;
1435
- console.log(chalk.cyan(c.name));
1441
+ // F22 provider-agnostic detail endpoint. The serialized shape
1442
+ // includes `provider`, `account_login`, `supports_contents_api`
1443
+ // and (for GitHub) `installation_id` / (BitBucket) `workspace_uuid`.
1444
+ const data = await api('GET', `/api/organizations/${orgId}/vcs/connections/${id}`, undefined, { noProjectHeader: true });
1445
+ const raw = data.connection ?? data;
1446
+ const c = normalizeConnection(raw);
1447
+ console.log(chalk.cyan(c.name ?? '(unnamed connection)'));
1436
1448
  console.log(` ID: ${c.id}`);
1437
- console.log(` GitHub: ${c.githubAccountLogin ?? chalk.gray('(pending)')}`);
1438
- if (c.githubAccountType)
1439
- console.log(` Account type: ${c.githubAccountType}`);
1449
+ // F22 §8.3 item 2 provider line above account info.
1450
+ console.log(` Provider: ${formatProviderLabel(c.provider)}`);
1451
+ console.log(` Account: ${c.accountLogin ?? chalk.gray('— (pending)')}`);
1452
+ if (c.accountType)
1453
+ console.log(` Account type: ${c.accountType}`);
1440
1454
  const statusColor = pickStatusColor(c.status);
1441
1455
  console.log(` Status: ${statusColor(c.status)}`);
1442
- console.log(` Installed: ${c.installedAt ? new Date(c.installedAt).toLocaleString() : '(unknown)'}`);
1456
+ if (c.installedAt) {
1457
+ console.log(` Installed: ${new Date(c.installedAt).toLocaleString()}`);
1458
+ }
1443
1459
  if (c.lastHealthCheckAt) {
1444
1460
  console.log(` Last checked: ${new Date(c.lastHealthCheckAt).toLocaleString()}`);
1445
1461
  }
1446
1462
  if (c.lastUsedAt) {
1447
1463
  console.log(` Last used: ${new Date(c.lastUsedAt).toLocaleString()}`);
1448
1464
  }
1449
- if (c.repositorySelection === 'all') {
1450
- console.log(` Repos: All repositories${c.githubAccountLogin ? ` in ${c.githubAccountLogin}` : ''}`);
1465
+ // F22 §6.4 supports_contents_api is the canonical signal for
1466
+ // "can this Connection back F18 docs surfaces?" Surface it so
1467
+ // users understand why docs may be unavailable on BitBucket.
1468
+ if (typeof c.supportsContentsApi === 'boolean') {
1469
+ const docs = c.supportsContentsApi ? 'yes' : chalk.yellow('no');
1470
+ console.log(` Docs API: ${docs}`);
1471
+ }
1472
+ if (raw.repositorySelection === 'all') {
1473
+ console.log(` Repos: All repositories${c.accountLogin ? ` in ${c.accountLogin}` : ''}`);
1451
1474
  }
1452
- else if (Array.isArray(c.repositories)) {
1453
- console.log(` Repos: ${c.repositories.length}${c.repositoriesTruncated ? ' (showing 100)' : ''}`);
1454
- for (const r of c.repositories) {
1455
- console.log(chalk.gray(` · ${r.fullName}`));
1475
+ else if (Array.isArray(raw.repositories)) {
1476
+ console.log(` Repos: ${raw.repositories.length}${raw.repositoriesTruncated ? ' (showing 100)' : ''}`);
1477
+ for (const r of raw.repositories) {
1478
+ console.log(chalk.gray(` · ${r.fullName ?? r.full_name ?? r.name}`));
1456
1479
  }
1457
1480
  }
1458
- if (Array.isArray(c.boundProjects) && c.boundProjects.length > 0) {
1481
+ const boundProjects = raw.boundProjects ?? raw.bound_projects ?? [];
1482
+ if (Array.isArray(boundProjects) && boundProjects.length > 0) {
1459
1483
  console.log('');
1460
1484
  console.log(chalk.cyan('Projects using this connection:'));
1461
- for (const p of c.boundProjects) {
1485
+ for (const p of boundProjects) {
1462
1486
  console.log(` · ${p.name} (${p.slug})`);
1463
1487
  }
1464
1488
  }
@@ -1479,12 +1503,55 @@ connections
1479
1503
  */
1480
1504
  connections
1481
1505
  .command('install')
1482
- .description('Add a GitHub Connection. Prints an install URL and waits for a confirmation code.')
1483
- .action(async () => {
1506
+ .description('Add a VCS Connection (GitHub or BitBucket). Prints an install URL and (for GitHub) waits for a confirmation code.')
1507
+ .option('--provider <provider>', 'VCS provider: github | bitbucket')
1508
+ .action(async (opts) => {
1484
1509
  try {
1485
1510
  const orgId = requireActiveOrgOrExit();
1486
1511
  const orgName = findOrgById(orgId)?.name ?? orgId;
1487
- const mintData = await api('POST', `/api/organizations/${orgId}/github/connections/install-url`, { source: 'cli', returnTo: null }, { noProjectHeader: true });
1512
+ // F22 §8.3 item 3 provider selection matrix.
1513
+ // 1) `--provider github|bitbucket` supplied: use directly.
1514
+ // 2) No flag: prompt the user interactively (provider-picker).
1515
+ // The CLI cannot probe server-side provider configuration, so the
1516
+ // "exactly one provider configured" auto-select branch of the spec
1517
+ // is implemented as "user always confirms" — narrower than the
1518
+ // spec wording but strictly safer (no silent provider choice).
1519
+ let provider = opts.provider?.toLowerCase();
1520
+ if (!provider) {
1521
+ provider = await promptProviderChoice();
1522
+ }
1523
+ if (provider !== 'github' && provider !== 'bitbucket') {
1524
+ console.error(chalk.red(`Invalid --provider: ${provider}. Use 'github' or 'bitbucket'.`));
1525
+ process.exit(1);
1526
+ }
1527
+ // F22 prefers the new provider-agnostic /vcs/connections route, which
1528
+ // expects `provider` in the body. The github-only endpoint is kept
1529
+ // alive but not used here so all new connections flow through one path.
1530
+ const mintData = await api('POST', `/api/organizations/${orgId}/vcs/connections/install-url`, { provider, source: 'cli', returnTo: null }, { noProjectHeader: true });
1531
+ // F22 §8.3 item 8 — BitBucket install is browser-only in v1 (no CLI
1532
+ // confirmation-code exchange counterpart). Print the literal copy
1533
+ // block specified in the spec verbatim, then exit successfully.
1534
+ // The user completes consent in the browser and the /api/bitbucket/
1535
+ // oauth/callback handler finishes the Connection server-side.
1536
+ //
1537
+ // Dex nit N4 — output literal MUST match the spec block exactly so
1538
+ // the acceptance test can assert it.
1539
+ if (provider === 'bitbucket') {
1540
+ console.log('');
1541
+ console.log('BitBucket Connection install — browser required');
1542
+ console.log('');
1543
+ console.log('Open this URL in your browser to authorize Fazemos:');
1544
+ console.log('');
1545
+ console.log(` ${mintData.url}`);
1546
+ console.log('');
1547
+ console.log('After you authorize, BitBucket will redirect to Fazemos and complete the Connection.');
1548
+ console.log('You can then list your connections with:');
1549
+ console.log('');
1550
+ console.log(' fazemos connections list');
1551
+ console.log('');
1552
+ console.log('This URL expires in 10 minutes.');
1553
+ return;
1554
+ }
1488
1555
  console.log('');
1489
1556
  console.log(`To add a GitHub connection to ${chalk.cyan(orgName)}:`);
1490
1557
  console.log('');
@@ -1573,18 +1640,20 @@ connections
1573
1640
  connections
1574
1641
  .command('revoke')
1575
1642
  .alias('disconnect')
1576
- .description('Disconnect a GitHub Connection (Fazemos-side; does not uninstall the App from GitHub).')
1643
+ .description('Disconnect a VCS Connection (Fazemos-side; does not uninstall the App from GitHub / revoke the OAuth grant on BitBucket).')
1577
1644
  .argument('<id>', 'Connection ID')
1578
1645
  .option('-f, --force', 'Skip the confirmation prompt', false)
1579
1646
  .action(async (id, opts) => {
1580
1647
  try {
1581
1648
  const orgId = requireActiveOrgOrExit();
1582
1649
  // Fetch the Connection so we can show what we're about to revoke
1583
- // and how many projects it affects (Sage §8.3 confirmation copy).
1584
- let connection;
1650
+ // and how many projects it affects (UX §8.3 confirmation copy).
1651
+ // F22 — use the provider-agnostic /vcs/ endpoint so both provider
1652
+ // rows are reachable.
1653
+ let raw;
1585
1654
  try {
1586
- const detail = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1587
- connection = detail.connection;
1655
+ const detail = await api('GET', `/api/organizations/${orgId}/vcs/connections/${id}`, undefined, { noProjectHeader: true });
1656
+ raw = detail.connection ?? detail;
1588
1657
  }
1589
1658
  catch (err) {
1590
1659
  if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
@@ -1593,15 +1662,17 @@ connections
1593
1662
  }
1594
1663
  throw err;
1595
1664
  }
1596
- const boundProjects = connection.boundProjects ?? [];
1665
+ const connection = normalizeConnection(raw);
1666
+ const boundProjects = raw.boundProjects ?? raw.bound_projects ?? [];
1667
+ const providerLabelStr = formatProviderLabel(connection.provider);
1597
1668
  if (!opts.force) {
1598
1669
  console.log('');
1599
- console.log(`Disconnect ${chalk.cyan(connection.name)}?`);
1670
+ console.log(`Disconnect ${chalk.magenta(providerLabelStr)} ${chalk.cyan(connection.name ?? id)}?`);
1600
1671
  console.log('');
1601
1672
  if (boundProjects.length > 0) {
1602
1673
  const projectList = boundProjects.map(p => p.name).join(', ');
1603
1674
  console.log(` This connection is used by ${boundProjects.length} ${boundProjects.length === 1 ? 'project' : 'projects'}: ${projectList}.`);
1604
- console.log(' Pipelines in those projects will fail on GitHub steps until');
1675
+ console.log(` Pipelines in those projects will fail on ${providerLabelStr} steps until`);
1605
1676
  console.log(' they are bound to a different connection.');
1606
1677
  console.log('');
1607
1678
  }
@@ -1609,7 +1680,13 @@ connections
1609
1680
  console.log(' No projects are using this connection.');
1610
1681
  console.log('');
1611
1682
  }
1612
- console.log(chalk.gray(' This does not uninstall the Fazemos App from GitHub.'));
1683
+ // Provider-aware caveat: GitHub App vs BitBucket OAuth grant.
1684
+ if (connection.provider === 'bitbucket') {
1685
+ console.log(chalk.gray(' This does not revoke the OAuth grant on BitBucket.'));
1686
+ }
1687
+ else {
1688
+ console.log(chalk.gray(' This does not uninstall the Fazemos App from GitHub.'));
1689
+ }
1613
1690
  console.log('');
1614
1691
  const answer = await promptLine('Disconnect? [y/N]: ');
1615
1692
  if (!answer || !/^y/i.test(answer.trim())) {
@@ -1617,11 +1694,14 @@ connections
1617
1694
  return;
1618
1695
  }
1619
1696
  }
1620
- const data = await api('DELETE', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1621
- console.log(chalk.green(`Disconnected: ${connection.name}`));
1622
- const affected = data.affectedProjects ?? [];
1623
- if (affected.length > 0) {
1624
- console.log(chalk.gray(` ${affected.length} project${affected.length === 1 ? '' : 's'} unbound.`));
1697
+ const data = await api('DELETE', `/api/organizations/${orgId}/vcs/connections/${id}`, undefined, { noProjectHeader: true });
1698
+ console.log(chalk.green(`Disconnected: ${providerLabelStr} ${connection.name ?? id}`));
1699
+ // F22 response shape: { revoked, secrets_deleted, projects_unbound }.
1700
+ // F16 legacy shape: { affectedProjects: [...] }. Handle both during
1701
+ // the dual-write rollout.
1702
+ const unbound = data.projects_unbound ?? (data.affectedProjects ?? []).length ?? 0;
1703
+ if (unbound > 0) {
1704
+ console.log(chalk.gray(` ${unbound} project${unbound === 1 ? '' : 's'} unbound.`));
1625
1705
  }
1626
1706
  invalidateAuthMeCache();
1627
1707
  }
@@ -1636,19 +1716,23 @@ connections
1636
1716
  */
1637
1717
  connections
1638
1718
  .command('health-check')
1639
- .description('Verify a Connection is still healthy on GitHub')
1719
+ .description('Verify a Connection is still healthy on its code platform (GitHub or BitBucket)')
1640
1720
  .argument('<id>', 'Connection ID')
1641
1721
  .action(async (id) => {
1642
1722
  try {
1643
1723
  const orgId = requireActiveOrgOrExit();
1644
- const data = await api('POST', `/api/organizations/${orgId}/github/connections/${id}/health-check`, {}, { noProjectHeader: true });
1645
- const c = data.connection;
1724
+ // F22 provider-aware health-check route on the /vcs/ alias.
1725
+ // The handler dispatches to the right provider (GitHub installation
1726
+ // probe / BitBucket OAuth-refresh + workspace probe).
1727
+ const data = await api('POST', `/api/organizations/${orgId}/vcs/connections/${id}/health-check`, {}, { noProjectHeader: true });
1728
+ const c = normalizeConnection(data.connection ?? data);
1646
1729
  const statusColor = pickStatusColor(c.status);
1730
+ const label = `${formatProviderLabel(c.provider)} ${c.name ?? id}`;
1647
1731
  if (data.changed) {
1648
- console.log(chalk.yellow(`Status changed: ${c.name} → ${statusColor(c.status)}`));
1732
+ console.log(chalk.yellow(`Status changed: ${label} → ${statusColor(c.status)}`));
1649
1733
  }
1650
1734
  else {
1651
- console.log(chalk.green(`✓ ${c.name} — ${statusColor(c.status)}`));
1735
+ console.log(chalk.green(`✓ ${label} — ${statusColor(c.status)}`));
1652
1736
  }
1653
1737
  }
1654
1738
  catch (err) {
@@ -1660,6 +1744,53 @@ connections
1660
1744
  process.exit(1);
1661
1745
  }
1662
1746
  });
1747
+ export function normalizeConnection(raw) {
1748
+ if (!raw || typeof raw !== 'object') {
1749
+ return {
1750
+ id: '',
1751
+ provider: null,
1752
+ name: null,
1753
+ accountLogin: null,
1754
+ accountType: null,
1755
+ status: 'unknown',
1756
+ installedAt: null,
1757
+ lastHealthCheckAt: null,
1758
+ lastUsedAt: null,
1759
+ supportsContentsApi: null,
1760
+ projectCount: null,
1761
+ };
1762
+ }
1763
+ // Provider — F22 new field. Older /github/ rows always meant github;
1764
+ // default there for back-compat.
1765
+ const provider = raw.provider ?? (raw.githubAccountLogin !== undefined ? 'github' : null);
1766
+ return {
1767
+ id: String(raw.id ?? ''),
1768
+ provider,
1769
+ name: raw.name ?? null,
1770
+ accountLogin: raw.accountLogin ?? raw.account_login ?? raw.githubAccountLogin ?? null,
1771
+ accountType: raw.accountType ?? raw.account_type ?? raw.githubAccountType ?? null,
1772
+ status: raw.status ?? 'unknown',
1773
+ installedAt: raw.installedAt ?? raw.installed_at ?? raw.created_at ?? null,
1774
+ lastHealthCheckAt: raw.lastHealthCheckAt ?? raw.last_health_check_at ?? null,
1775
+ lastUsedAt: raw.lastUsedAt ?? raw.last_used_at ?? null,
1776
+ supportsContentsApi: typeof raw.supportsContentsApi === 'boolean'
1777
+ ? raw.supportsContentsApi
1778
+ : typeof raw.supports_contents_api === 'boolean'
1779
+ ? raw.supports_contents_api
1780
+ : null,
1781
+ projectCount: raw.projectCount ?? raw.project_count ?? null,
1782
+ };
1783
+ }
1784
+ /**
1785
+ * F22 — Human-friendly provider label used in CLI columns and lines.
1786
+ */
1787
+ export function formatProviderLabel(provider) {
1788
+ if (provider === 'github')
1789
+ return 'GitHub';
1790
+ if (provider === 'bitbucket')
1791
+ return 'BitBucket';
1792
+ return '—';
1793
+ }
1663
1794
  /**
1664
1795
  * Helper — color a status string per the Sage §2.2 taxonomy.
1665
1796
  */
@@ -1679,6 +1810,30 @@ function pickStatusColor(status) {
1679
1810
  return chalk.white;
1680
1811
  }
1681
1812
  }
1813
+ /**
1814
+ * F22 §8.3 item 3 — interactive provider picker. Used by
1815
+ * `fazemos connections install` when no `--provider` flag is supplied
1816
+ * so the user explicitly chooses GitHub or BitBucket before the URL
1817
+ * mint hits the API. Mirrors the web `ProviderPickerModal` choice
1818
+ * without the modal chrome.
1819
+ *
1820
+ * Returns 'github' or 'bitbucket'. Repeats on invalid input.
1821
+ */
1822
+ export async function promptProviderChoice() {
1823
+ console.log('');
1824
+ console.log('Which code platform do you want to connect?');
1825
+ console.log(' 1. GitHub');
1826
+ console.log(' 2. BitBucket');
1827
+ console.log('');
1828
+ while (true) {
1829
+ const ans = (await promptLine(' Choose [1=GitHub / 2=BitBucket]: ')).trim().toLowerCase();
1830
+ if (ans === '1' || ans === 'github' || ans === 'gh')
1831
+ return 'github';
1832
+ if (ans === '2' || ans === 'bitbucket' || ans === 'bb')
1833
+ return 'bitbucket';
1834
+ console.log(chalk.yellow(' Please answer 1 (GitHub) or 2 (BitBucket).'));
1835
+ }
1836
+ }
1682
1837
  /**
1683
1838
  * Read a single line of input from stdin. No fancy framing — `readline`
1684
1839
  * is sufficient for the install confirmation-code prompt and the
@@ -7898,6 +8053,151 @@ Examples:
7898
8053
  if (summary.errors > 0)
7899
8054
  process.exit(1);
7900
8055
  });
8056
+ // ── `fazemos dispatch` — role-to-role dispatch ─────────────────────
8057
+ //
8058
+ // Writes an inbox markdown file in the recipient role's operating dir and
8059
+ // calls POST /api/notifications/dispatch so the API can fire any
8060
+ // configured human-filler notifications (Slack today).
8061
+ //
8062
+ // Recipients are resolved via `.fazemos/roles.json` walked up from cwd.
8063
+ // Cross-workspace roles are followed via the local registry's
8064
+ // `cross_workspace_roles` block.
8065
+ //
8066
+ // Example:
8067
+ // fazemos dispatch founder question --from business-strategist \
8068
+ // --body "Should we ship F19 before F7?" --priority high --commit
8069
+ program
8070
+ .command('dispatch <to> <type>')
8071
+ .description(`Write an inbox markdown file in <to>'s operating dir and (optionally) fire
8072
+ notifications via the API.
8073
+
8074
+ Recipients are resolved via the nearest .fazemos/roles.json registry, walking
8075
+ up from the current directory. Cross-workspace recipients are followed via
8076
+ the local registry's cross_workspace_roles block.
8077
+
8078
+ Types: question | task | signal | response | flag | decision | direction`)
8079
+ .requiredOption('--from <role>', 'sender role-slug (required)')
8080
+ .option('--body <text>', 'markdown body of the dispatch (or use --body-file)')
8081
+ .option('--body-file <path>', 'read body from a file')
8082
+ .option('--priority <level>', 'low | normal | high', 'normal')
8083
+ .option('--re <ref>', 'optional reference (worksheet id, file path, etc.)')
8084
+ .option('--thread <id>', 'optional prior item id this responds to')
8085
+ .option('--expires-at <iso>', 'optional deadline (ISO 8601)')
8086
+ .option('--commit', 'git add + commit the inbox file after writing')
8087
+ .option('--no-notify', 'skip the API notification call (file-only)')
8088
+ .action(async (to, type, opts) => {
8089
+ try {
8090
+ // Validate type
8091
+ const allowedTypes = ['question', 'task', 'signal', 'response', 'flag', 'decision', 'direction'];
8092
+ if (!allowedTypes.includes(type)) {
8093
+ throw new Error(`Invalid type "${type}". Allowed: ${allowedTypes.join(', ')}`);
8094
+ }
8095
+ // Resolve body
8096
+ let body = opts.body;
8097
+ if (!body && opts.bodyFile) {
8098
+ body = readFileSync(opts.bodyFile, 'utf-8').trim();
8099
+ }
8100
+ if (!body) {
8101
+ throw new Error('--body or --body-file is required');
8102
+ }
8103
+ // Find local registry
8104
+ const localRegistry = findLocalRegistry(process.cwd());
8105
+ if (!localRegistry) {
8106
+ throw new Error(`No .fazemos/roles.json found in cwd or any parent. ` +
8107
+ `Run from inside a Fazemos-aware workspace.`);
8108
+ }
8109
+ // Resolve recipient
8110
+ const resolved = resolveRole(to, localRegistry);
8111
+ if (!resolved) {
8112
+ throw new Error(`Role "${to}" not found in local registry or any cross-workspace ref. ` +
8113
+ `Check .fazemos/roles.json.`);
8114
+ }
8115
+ const { role, registry } = resolved;
8116
+ const input = {
8117
+ to,
8118
+ from: opts.from,
8119
+ type: type,
8120
+ priority: (opts.priority ?? 'normal'),
8121
+ body,
8122
+ re: opts.re,
8123
+ thread: opts.thread,
8124
+ expiresAt: opts.expiresAt,
8125
+ };
8126
+ // Build + write file
8127
+ const { filename, content, summary } = buildInboxFile(input);
8128
+ const fullPath = writeInboxFile(registry._workspaceRoot, role, filename, content);
8129
+ const relPath = fullPath.startsWith(registry._workspaceRoot)
8130
+ ? fullPath.slice(registry._workspaceRoot.length + 1)
8131
+ : fullPath;
8132
+ console.log(chalk.green(`✓ Wrote inbox file:`));
8133
+ console.log(` ${chalk.cyan(fullPath)}`);
8134
+ console.log(` Workspace: ${registry.workspace} Filler: ${role.filler.identity} (${role.filler.type})`);
8135
+ // Notify (unless --no-notify)
8136
+ if (opts.notify !== false) {
8137
+ try {
8138
+ const payload = buildNotificationPayload(input, role, relPath, summary);
8139
+ const resp = await api('POST', '/api/notifications/dispatch', payload);
8140
+ if (resp.notified) {
8141
+ console.log(chalk.green(`✓ Notification fired: ${resp.channel}`));
8142
+ }
8143
+ else {
8144
+ console.log(chalk.gray(` (no notification fired — ${resp.reason ?? 'unknown'})`));
8145
+ }
8146
+ }
8147
+ catch (err) {
8148
+ const msg = err instanceof Error ? err.message : String(err);
8149
+ console.log(chalk.yellow(` warning: notification call failed — ${msg}`));
8150
+ console.log(chalk.yellow(` (inbox file was still written successfully)`));
8151
+ }
8152
+ }
8153
+ else {
8154
+ console.log(chalk.gray(' (--no-notify; API not called)'));
8155
+ }
8156
+ // Commit if requested
8157
+ if (opts.commit) {
8158
+ try {
8159
+ gitCommitInboxFile(registry._workspaceRoot, relPath, `dispatch(${opts.from} → ${to}): ${type}`);
8160
+ console.log(chalk.green('✓ Committed.'));
8161
+ }
8162
+ catch (err) {
8163
+ const msg = err instanceof Error ? err.message : String(err);
8164
+ console.log(chalk.yellow(` warning: commit failed — ${msg}`));
8165
+ }
8166
+ }
8167
+ }
8168
+ catch (err) {
8169
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
8170
+ process.exit(1);
8171
+ }
8172
+ });
8173
+ program
8174
+ .command('dispatch-list-roles')
8175
+ .description('List roles available in the nearest .fazemos/roles.json registry')
8176
+ .action(() => {
8177
+ const reg = findLocalRegistry(process.cwd());
8178
+ if (!reg) {
8179
+ console.error(chalk.red('No .fazemos/roles.json found in cwd or any parent.'));
8180
+ process.exit(1);
8181
+ }
8182
+ console.log(chalk.cyan(`Registry: ${reg._registryPath}`));
8183
+ console.log(chalk.cyan(`Workspace: ${reg.workspace} Org: ${reg.org}${reg.project ? ' Project: ' + reg.project : ''}`));
8184
+ console.log();
8185
+ console.log(chalk.bold('Local roles:'));
8186
+ for (const [slug, role] of Object.entries(reg.roles)) {
8187
+ const fillerTag = role.filler.type === 'human'
8188
+ ? chalk.yellow(`${role.filler.identity} (human)`)
8189
+ : chalk.gray(`${role.filler.identity} (agent)`);
8190
+ const notifyTag = role.notification ? chalk.green(' [notify]') : '';
8191
+ console.log(` ${chalk.cyan(slug.padEnd(32))} ${fillerTag}${notifyTag}`);
8192
+ }
8193
+ if (reg.cross_workspace_roles) {
8194
+ console.log();
8195
+ console.log(chalk.bold('Cross-workspace roles:'));
8196
+ for (const [slug, xref] of Object.entries(reg.cross_workspace_roles)) {
8197
+ console.log(` ${chalk.cyan(slug.padEnd(32))} → ${xref.workspace_path}`);
8198
+ }
8199
+ }
8200
+ });
7901
8201
  // Skip auto-parse only when running under Vitest (which sets process.env.VITEST).
7902
8202
  // Tests import `program` and drive it via `program.parseAsync(...)` after mocking
7903
8203
  // `./api.js`. In every other context — direct invocation, npx tsx, OR the bin