@formigio/fazemos-cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * F16 — Role-aware copy for the `PROJECT_CONNECTION_UNAVAILABLE`
3
+ * structured error (tech spec §5.14).
4
+ *
5
+ * Mirrors the web's `renderProjectConnectionUnavailableCopy` so admin
6
+ * and member experiences stay consistent across surfaces. CLI variant
7
+ * adds the settings URL as a trailing line for admins; members see
8
+ * "Ask your admin" without a URL.
9
+ *
10
+ * The `getEnv()` helper supplies the web origin so we can construct
11
+ * fully-qualified URLs the user can click in the terminal.
12
+ */
13
+ /**
14
+ * Structured payload returned by pipeline-run endpoints when the
15
+ * Project's Connection is missing/revoked/suspended/uninstalled.
16
+ * Matches the contract in tech spec §5.14.
17
+ */
18
+ export interface ProjectConnectionUnavailableErrorBody {
19
+ error: 'project_connection_unavailable';
20
+ code: 'PROJECT_CONNECTION_UNAVAILABLE';
21
+ reason: 'missing' | 'revoked' | 'suspended' | 'uninstalled';
22
+ orgId: string;
23
+ orgSlug: string;
24
+ projectId: string;
25
+ projectSlug: string;
26
+ connectionId: string | null;
27
+ }
28
+ export interface ConnectionErrorLines {
29
+ /** Lead headline. */
30
+ title: string;
31
+ /** Body explanation. */
32
+ body: string;
33
+ /** Optional fully-qualified URL line, only set for admin role. */
34
+ ctaUrl?: string;
35
+ }
36
+ /**
37
+ * Type guard — narrow `ApiError.body` (or any unknown payload) to the
38
+ * structured shape so callers can render copy.
39
+ */
40
+ export declare function isProjectConnectionUnavailable(body: unknown): body is ProjectConnectionUnavailableErrorBody;
41
+ /**
42
+ * Produce the role-aware lines for terminal rendering. Caller decides
43
+ * how to color / format. The shape is plain so a JSON test fixture
44
+ * stays readable.
45
+ */
46
+ export declare function renderProjectConnectionUnavailableCopy(error: Pick<ProjectConnectionUnavailableErrorBody, 'reason' | 'orgSlug' | 'connectionId'>, role: 'owner' | 'admin' | 'member' | string): ConnectionErrorLines;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * F16 — Role-aware copy for the `PROJECT_CONNECTION_UNAVAILABLE`
3
+ * structured error (tech spec §5.14).
4
+ *
5
+ * Mirrors the web's `renderProjectConnectionUnavailableCopy` so admin
6
+ * and member experiences stay consistent across surfaces. CLI variant
7
+ * adds the settings URL as a trailing line for admins; members see
8
+ * "Ask your admin" without a URL.
9
+ *
10
+ * The `getEnv()` helper supplies the web origin so we can construct
11
+ * fully-qualified URLs the user can click in the terminal.
12
+ */
13
+ import { getEnv } from './config.js';
14
+ /**
15
+ * Type guard — narrow `ApiError.body` (or any unknown payload) to the
16
+ * structured shape so callers can render copy.
17
+ */
18
+ export function isProjectConnectionUnavailable(body) {
19
+ if (!body || typeof body !== 'object')
20
+ return false;
21
+ const b = body;
22
+ return (b.code === 'PROJECT_CONNECTION_UNAVAILABLE' &&
23
+ typeof b.reason === 'string' &&
24
+ ['missing', 'revoked', 'suspended', 'uninstalled'].includes(b.reason) &&
25
+ typeof b.orgSlug === 'string' &&
26
+ typeof b.projectSlug === 'string');
27
+ }
28
+ /**
29
+ * Resolve the web origin (where the settings URLs live). The CLI's
30
+ * `env` config holds an `apiUrl` like `https://api.fazemos.../api`.
31
+ * The web origin is conventionally the same host minus `api`. For
32
+ * local dev (`http://localhost:8080`) we point at the dev web URL.
33
+ *
34
+ * Conservative behavior: if we can't derive cleanly, render the path
35
+ * without an origin (the user can still see what to do).
36
+ */
37
+ function deriveWebOrigin() {
38
+ try {
39
+ const env = getEnv();
40
+ // Prod: api.fazemos.journeyjuntos.com → fazemos.journeyjuntos.com
41
+ // Dev: api.fazemos-dev.journeyjuntos.com → fazemos-dev.journeyjuntos.com
42
+ // Local: http://localhost:8080 → http://localhost:5173
43
+ const apiUrl = env.apiUrl;
44
+ if (!apiUrl)
45
+ return null;
46
+ // Local — vite dev port for the web app.
47
+ if (apiUrl.includes('localhost') || apiUrl.includes('127.0.0.1')) {
48
+ return 'http://localhost:5173';
49
+ }
50
+ // Strip a leading `api.` if present in the host.
51
+ const url = new URL(apiUrl);
52
+ const host = url.host.replace(/^api\./, '');
53
+ return `${url.protocol}//${host}`;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /**
60
+ * Produce the role-aware lines for terminal rendering. Caller decides
61
+ * how to color / format. The shape is plain so a JSON test fixture
62
+ * stays readable.
63
+ */
64
+ export function renderProjectConnectionUnavailableCopy(error, role) {
65
+ const isAdmin = role === 'owner' || role === 'admin';
66
+ const origin = deriveWebOrigin();
67
+ const settingsPath = error.connectionId
68
+ ? `/org/${error.orgSlug}/settings/connections/${error.connectionId}`
69
+ : `/org/${error.orgSlug}/settings/connections`;
70
+ const ctaUrl = origin ? `${origin}${settingsPath}` : settingsPath;
71
+ if (error.reason === 'missing') {
72
+ return isAdmin
73
+ ? {
74
+ title: 'Connect GitHub to run this pipeline.',
75
+ body: 'This project needs a GitHub connection to clone its repos.',
76
+ ctaUrl,
77
+ }
78
+ : {
79
+ title: 'This project needs a GitHub connection.',
80
+ body: 'Ask your admin to add one in Project Settings.',
81
+ };
82
+ }
83
+ if (error.reason === 'revoked' ||
84
+ error.reason === 'uninstalled' ||
85
+ error.reason === 'suspended') {
86
+ return isAdmin
87
+ ? {
88
+ title: `GitHub connection ${error.reason}.`,
89
+ body: 'New pipeline steps that need GitHub will fail until the project is bound to a different connection.',
90
+ ctaUrl,
91
+ }
92
+ : {
93
+ title: 'This project needs a GitHub connection.',
94
+ body: 'Ask your admin — the existing connection is not usable.',
95
+ };
96
+ }
97
+ return {
98
+ title: 'GitHub connection is unavailable.',
99
+ body: '',
100
+ };
101
+ }
102
+ //# sourceMappingURL=connectionErrorCopy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connectionErrorCopy.js","sourceRoot":"","sources":["../src/connectionErrorCopy.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironmen
6
6
  getActiveProjectId, setActiveProjectId, clearActiveProjectId, findProjectBySlug, findProjectById, findOrgById, } from './config.js';
7
7
  import { login, signup, confirmSignup, adminLogin } from './auth.js';
8
8
  import { api, ApiError, refreshAuthMeCache, invalidateAuthMeCache } from './api.js';
9
+ import { isProjectConnectionUnavailable, renderProjectConnectionUnavailableCopy, } from './connectionErrorCopy.js';
9
10
  import { execSync } from 'child_process';
10
11
  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
11
12
  import { fileURLToPath } from 'url';
@@ -799,6 +800,26 @@ function requireActiveOrgOrExit() {
799
800
  }
800
801
  return orgId;
801
802
  }
803
+ /**
804
+ * F16 — Resolve the caller's role in the active Org from the cached
805
+ * /auth/me response. Used to branch the `PROJECT_CONNECTION_UNAVAILABLE`
806
+ * error copy (tech spec §5.14) — admin variant includes a settings URL,
807
+ * member variant does not.
808
+ *
809
+ * Falls back to 'member' if the cache is empty or the active Org isn't
810
+ * present in it. Conservative — better to render the no-CTA copy than
811
+ * mislead a non-admin into clicking a settings link they can't act on.
812
+ */
813
+ function resolveActiveOrgRole() {
814
+ const orgId = getActiveOrgId();
815
+ if (!orgId)
816
+ return 'member';
817
+ const org = findOrgById(orgId);
818
+ const role = org?.role;
819
+ if (role === 'owner' || role === 'admin' || role === 'member')
820
+ return role;
821
+ return 'member';
822
+ }
802
823
  notifications
803
824
  .command('get')
804
825
  .description('Show current notification config (events enabled, webhook source)')
@@ -1230,6 +1251,374 @@ projects
1230
1251
  process.exit(1);
1231
1252
  }
1232
1253
  });
1254
+ // ── F16 — projects set-connection (binds a Connection to a project) ─
1255
+ projects
1256
+ .command('set-connection')
1257
+ .description('Bind a GitHub Connection to a project. Pass "none" to unbind. Owner/admin only.')
1258
+ .argument('<slug>', 'Project slug')
1259
+ .argument('<connection>', 'Connection ID, or "none" to unbind')
1260
+ .action(async (slug, connection) => {
1261
+ try {
1262
+ const orgId = requireActiveOrgOrExit();
1263
+ let project = findProjectBySlug(orgId, slug);
1264
+ if (!project) {
1265
+ await refreshAuthMeCache();
1266
+ project = findProjectBySlug(orgId, slug);
1267
+ }
1268
+ if (!project) {
1269
+ console.error(chalk.red(`Unknown project: ${slug}`));
1270
+ console.log(chalk.gray('Run: fazemos projects list'));
1271
+ process.exit(1);
1272
+ }
1273
+ const githubConnectionId = connection === 'none' ? null : connection;
1274
+ const data = await api('PATCH', `/api/organizations/${orgId}/projects/${project.id}`, { githubConnectionId }, { noProjectHeader: true });
1275
+ const p = data.project;
1276
+ if (githubConnectionId === null) {
1277
+ console.log(chalk.green(`✓ ${p.name} (${p.slug}) — GitHub connection cleared`));
1278
+ }
1279
+ else {
1280
+ // The detail endpoint may not return the connection's display
1281
+ // name; show the bound id and let the user run `fazemos
1282
+ // connections show <id>` for full details.
1283
+ console.log(chalk.green(`✓ ${p.name} (${p.slug}) is now using connection ${githubConnectionId}`));
1284
+ }
1285
+ invalidateAuthMeCache();
1286
+ }
1287
+ catch (err) {
1288
+ if (err instanceof ApiError) {
1289
+ if (err.code === 'CONNECTION_NOT_ACTIVE') {
1290
+ const body = (err.body ?? {});
1291
+ const reasonLabel = body.reason ?? 'not active';
1292
+ console.error(chalk.red(`Connection is ${reasonLabel} and can't be bound to a project.`));
1293
+ console.log(chalk.gray('Pick a different connection: fazemos connections list'));
1294
+ process.exit(1);
1295
+ }
1296
+ if (err.code === 'CONNECTION_NOT_FOUND') {
1297
+ console.error(chalk.red('Connection not found.'));
1298
+ console.log(chalk.gray('List your connections: fazemos connections list'));
1299
+ process.exit(1);
1300
+ }
1301
+ if (err.code === 'CONNECTION_CROSS_ORG') {
1302
+ console.error(chalk.red("That connection belongs to a different organization."));
1303
+ process.exit(1);
1304
+ }
1305
+ }
1306
+ console.error(chalk.red(err.message));
1307
+ process.exit(1);
1308
+ }
1309
+ });
1310
+ // ── F16 — Connections ───────────────────────────────────────
1311
+ const connections = program.command('connections').alias('conn').description('GitHub Connection commands');
1312
+ /**
1313
+ * `fazemos connections list` — list active+pending Connections in the
1314
+ * active Org. Output is tabular per Sage UX §8.3.
1315
+ */
1316
+ connections
1317
+ .command('list')
1318
+ .description('List GitHub Connections in the active organization')
1319
+ .option('-s, --status <status>', 'Filter: active (default), all, pending, suspended, revoked, uninstalled', 'active')
1320
+ .action(async (opts) => {
1321
+ try {
1322
+ const orgId = requireActiveOrgOrExit();
1323
+ const data = await api('GET', `/api/organizations/${orgId}/github/connections?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
1324
+ const list = data.connections ?? [];
1325
+ if (list.length === 0) {
1326
+ const orgName = findOrgById(orgId)?.name ?? orgId;
1327
+ console.log(chalk.yellow(`No GitHub connections in ${orgName}.`));
1328
+ console.log(chalk.gray('Add one with: fazemos connections install'));
1329
+ return;
1330
+ }
1331
+ const orgName = findOrgById(orgId)?.name ?? orgId;
1332
+ console.log(chalk.cyan(`GitHub connections in ${orgName}:`));
1333
+ console.log('');
1334
+ for (const c of list) {
1335
+ const statusColor = pickStatusColor(c.status);
1336
+ const projects = c.projectCount == null ? '—' : `${c.projectCount} ${c.projectCount === 1 ? 'project' : 'projects'}`;
1337
+ const login = c.githubAccountLogin ?? chalk.gray('—');
1338
+ console.log(` ${chalk.cyan(c.name)} ${chalk.gray(login)} ${statusColor(c.status)} ${chalk.gray(projects)}`);
1339
+ console.log(chalk.gray(` ID: ${c.id}`));
1340
+ }
1341
+ }
1342
+ catch (err) {
1343
+ console.error(chalk.red(err.message));
1344
+ process.exit(1);
1345
+ }
1346
+ });
1347
+ /**
1348
+ * `fazemos connections show <id>` — full detail including repos and
1349
+ * bound projects.
1350
+ */
1351
+ connections
1352
+ .command('show')
1353
+ .description('Show a GitHub Connection in detail')
1354
+ .argument('<id>', 'Connection ID')
1355
+ .action(async (id) => {
1356
+ try {
1357
+ const orgId = requireActiveOrgOrExit();
1358
+ const data = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1359
+ const c = data.connection;
1360
+ console.log(chalk.cyan(c.name));
1361
+ console.log(` ID: ${c.id}`);
1362
+ console.log(` GitHub: ${c.githubAccountLogin ?? chalk.gray('— (pending)')}`);
1363
+ if (c.githubAccountType)
1364
+ console.log(` Account type: ${c.githubAccountType}`);
1365
+ const statusColor = pickStatusColor(c.status);
1366
+ console.log(` Status: ${statusColor(c.status)}`);
1367
+ console.log(` Installed: ${c.installedAt ? new Date(c.installedAt).toLocaleString() : '(unknown)'}`);
1368
+ if (c.lastHealthCheckAt) {
1369
+ console.log(` Last checked: ${new Date(c.lastHealthCheckAt).toLocaleString()}`);
1370
+ }
1371
+ if (c.lastUsedAt) {
1372
+ console.log(` Last used: ${new Date(c.lastUsedAt).toLocaleString()}`);
1373
+ }
1374
+ if (c.repositorySelection === 'all') {
1375
+ console.log(` Repos: All repositories${c.githubAccountLogin ? ` in ${c.githubAccountLogin}` : ''}`);
1376
+ }
1377
+ else if (Array.isArray(c.repositories)) {
1378
+ console.log(` Repos: ${c.repositories.length}${c.repositoriesTruncated ? ' (showing 100)' : ''}`);
1379
+ for (const r of c.repositories) {
1380
+ console.log(chalk.gray(` · ${r.fullName}`));
1381
+ }
1382
+ }
1383
+ if (Array.isArray(c.boundProjects) && c.boundProjects.length > 0) {
1384
+ console.log('');
1385
+ console.log(chalk.cyan('Projects using this connection:'));
1386
+ for (const p of c.boundProjects) {
1387
+ console.log(` · ${p.name} (${p.slug})`);
1388
+ }
1389
+ }
1390
+ }
1391
+ catch (err) {
1392
+ if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
1393
+ console.error(chalk.red('Connection not found.'));
1394
+ console.log(chalk.gray('List your connections: fazemos connections list'));
1395
+ process.exit(1);
1396
+ }
1397
+ console.error(chalk.red(err.message));
1398
+ process.exit(1);
1399
+ }
1400
+ });
1401
+ /**
1402
+ * `fazemos connections install` — print-URL + confirmation-code flow per
1403
+ * Sage UX §8.1. Two API calls: mint URL → exchange code.
1404
+ */
1405
+ connections
1406
+ .command('install')
1407
+ .description('Add a GitHub Connection. Prints an install URL and waits for a confirmation code.')
1408
+ .action(async () => {
1409
+ try {
1410
+ const orgId = requireActiveOrgOrExit();
1411
+ const orgName = findOrgById(orgId)?.name ?? orgId;
1412
+ const mintData = await api('POST', `/api/organizations/${orgId}/github/connections/install-url`, { source: 'cli', returnTo: null }, { noProjectHeader: true });
1413
+ console.log('');
1414
+ console.log(`To add a GitHub connection to ${chalk.cyan(orgName)}:`);
1415
+ console.log('');
1416
+ console.log(' 1. Open this URL in your browser (expires in 10 minutes):');
1417
+ console.log(` ${chalk.cyan(mintData.url)}`);
1418
+ console.log('');
1419
+ console.log(' 2. Install the Fazemos App on your GitHub org or account.');
1420
+ console.log('');
1421
+ console.log(' 3. After installing, you\'ll see a confirmation code on the page.');
1422
+ console.log(' Paste it here:');
1423
+ console.log('');
1424
+ const code = await promptLine(' Code: ');
1425
+ if (!code) {
1426
+ console.error(chalk.red('No code provided. Aborted.'));
1427
+ process.exit(1);
1428
+ }
1429
+ try {
1430
+ const exchangeData = await api('POST', '/api/github/connections/exchange-code', { code: code.trim() }, { noProjectHeader: true });
1431
+ const c = exchangeData.connection;
1432
+ console.log('');
1433
+ console.log(chalk.green(` ✓ Connected: ${c.name}${c.githubAccountLogin ? ` (${c.githubAccountLogin})` : ''}`));
1434
+ if (c.repositorySelection === 'all') {
1435
+ console.log(chalk.gray(` Repos: All repositories${c.githubAccountLogin ? ` in ${c.githubAccountLogin}` : ''}`));
1436
+ }
1437
+ console.log(chalk.gray(` Connection ID: ${c.id}`));
1438
+ console.log('');
1439
+ console.log(chalk.gray(' To use this in a project:'));
1440
+ console.log(chalk.gray(` fazemos projects set-connection <project-slug> ${c.id}`));
1441
+ invalidateAuthMeCache();
1442
+ }
1443
+ catch (err) {
1444
+ if (err instanceof ApiError) {
1445
+ if (err.code === 'CODE_EXPIRED') {
1446
+ console.error(chalk.red(' ✗ That code expired. Codes are valid for 10 minutes.'));
1447
+ console.log(chalk.gray(' Try again: fazemos connections install'));
1448
+ process.exit(1);
1449
+ }
1450
+ if (err.code === 'CODE_ALREADY_USED') {
1451
+ console.error(chalk.red(' ✗ That code was already used.'));
1452
+ console.log(chalk.gray(' Start over: fazemos connections install'));
1453
+ process.exit(1);
1454
+ }
1455
+ if (err.code === 'CODE_NOT_FOUND') {
1456
+ console.error(chalk.red(' ✗ That code didn\'t match.'));
1457
+ console.log(chalk.gray(' Double-check it or start over: fazemos connections install'));
1458
+ process.exit(1);
1459
+ }
1460
+ if (err.code === 'CODE_WRONG_USER') {
1461
+ console.error(chalk.red(' ✗ That code was generated by a different user.'));
1462
+ console.log(chalk.gray(' Start over: fazemos connections install'));
1463
+ process.exit(1);
1464
+ }
1465
+ if (err.code === 'CODE_INVALID') {
1466
+ console.error(chalk.red(' ✗ That code doesn\'t look right.'));
1467
+ console.log(chalk.gray(' Start over: fazemos connections install'));
1468
+ process.exit(1);
1469
+ }
1470
+ if (err.code === 'CODE_RATE_LIMITED') {
1471
+ const body = (err.body ?? {});
1472
+ const after = body.retryAfter ? `${body.retryAfter}s` : '60s';
1473
+ console.error(chalk.red(` ✗ Too many code attempts. Try again in ${after}.`));
1474
+ process.exit(1);
1475
+ }
1476
+ }
1477
+ throw err;
1478
+ }
1479
+ }
1480
+ catch (err) {
1481
+ if (err instanceof ApiError && err.code === 'MISSING_GITHUB_CONFIG') {
1482
+ console.error(chalk.red('GitHub App is not configured on this server.'));
1483
+ console.log(chalk.gray('Contact your Fazemos operator.'));
1484
+ process.exit(1);
1485
+ }
1486
+ if (err instanceof ApiError && err.code === 'FORBIDDEN_ROLE') {
1487
+ console.error(chalk.red('Only org owners and admins can add GitHub connections.'));
1488
+ process.exit(1);
1489
+ }
1490
+ console.error(chalk.red(err.message));
1491
+ process.exit(1);
1492
+ }
1493
+ });
1494
+ /**
1495
+ * `fazemos connections revoke <id>` — disconnect with confirmation
1496
+ * prompt. `--force` skips the prompt for scripted use.
1497
+ */
1498
+ connections
1499
+ .command('revoke')
1500
+ .alias('disconnect')
1501
+ .description('Disconnect a GitHub Connection (Fazemos-side; does not uninstall the App from GitHub).')
1502
+ .argument('<id>', 'Connection ID')
1503
+ .option('-f, --force', 'Skip the confirmation prompt', false)
1504
+ .action(async (id, opts) => {
1505
+ try {
1506
+ const orgId = requireActiveOrgOrExit();
1507
+ // Fetch the Connection so we can show what we're about to revoke
1508
+ // and how many projects it affects (Sage §8.3 confirmation copy).
1509
+ let connection;
1510
+ try {
1511
+ const detail = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1512
+ connection = detail.connection;
1513
+ }
1514
+ catch (err) {
1515
+ if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
1516
+ console.error(chalk.red('Connection not found.'));
1517
+ process.exit(1);
1518
+ }
1519
+ throw err;
1520
+ }
1521
+ const boundProjects = connection.boundProjects ?? [];
1522
+ if (!opts.force) {
1523
+ console.log('');
1524
+ console.log(`Disconnect ${chalk.cyan(connection.name)}?`);
1525
+ console.log('');
1526
+ if (boundProjects.length > 0) {
1527
+ const projectList = boundProjects.map(p => p.name).join(', ');
1528
+ console.log(` This connection is used by ${boundProjects.length} ${boundProjects.length === 1 ? 'project' : 'projects'}: ${projectList}.`);
1529
+ console.log(' Pipelines in those projects will fail on GitHub steps until');
1530
+ console.log(' they are bound to a different connection.');
1531
+ console.log('');
1532
+ }
1533
+ else {
1534
+ console.log(' No projects are using this connection.');
1535
+ console.log('');
1536
+ }
1537
+ console.log(chalk.gray(' This does not uninstall the Fazemos App from GitHub.'));
1538
+ console.log('');
1539
+ const answer = await promptLine('Disconnect? [y/N]: ');
1540
+ if (!answer || !/^y/i.test(answer.trim())) {
1541
+ console.log(chalk.gray('Cancelled.'));
1542
+ return;
1543
+ }
1544
+ }
1545
+ const data = await api('DELETE', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
1546
+ console.log(chalk.green(`Disconnected: ${connection.name}`));
1547
+ const affected = data.affectedProjects ?? [];
1548
+ if (affected.length > 0) {
1549
+ console.log(chalk.gray(` ${affected.length} project${affected.length === 1 ? '' : 's'} unbound.`));
1550
+ }
1551
+ invalidateAuthMeCache();
1552
+ }
1553
+ catch (err) {
1554
+ console.error(chalk.red(err.message));
1555
+ process.exit(1);
1556
+ }
1557
+ });
1558
+ /**
1559
+ * `fazemos connections health-check <id>` — manually verify with
1560
+ * GitHub. Updates status and last_health_check_at.
1561
+ */
1562
+ connections
1563
+ .command('health-check')
1564
+ .description('Verify a Connection is still healthy on GitHub')
1565
+ .argument('<id>', 'Connection ID')
1566
+ .action(async (id) => {
1567
+ try {
1568
+ const orgId = requireActiveOrgOrExit();
1569
+ const data = await api('POST', `/api/organizations/${orgId}/github/connections/${id}/health-check`, {}, { noProjectHeader: true });
1570
+ const c = data.connection;
1571
+ const statusColor = pickStatusColor(c.status);
1572
+ if (data.changed) {
1573
+ console.log(chalk.yellow(`Status changed: ${c.name} → ${statusColor(c.status)}`));
1574
+ }
1575
+ else {
1576
+ console.log(chalk.green(`✓ ${c.name} — ${statusColor(c.status)}`));
1577
+ }
1578
+ }
1579
+ catch (err) {
1580
+ if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
1581
+ console.error(chalk.red('Connection not found.'));
1582
+ process.exit(1);
1583
+ }
1584
+ console.error(chalk.red(err.message));
1585
+ process.exit(1);
1586
+ }
1587
+ });
1588
+ /**
1589
+ * Helper — color a status string per the Sage §2.2 taxonomy.
1590
+ */
1591
+ function pickStatusColor(status) {
1592
+ switch (status) {
1593
+ case 'active':
1594
+ return chalk.green;
1595
+ case 'pending':
1596
+ return chalk.yellow;
1597
+ case 'suspended':
1598
+ return chalk.yellow;
1599
+ case 'uninstalled':
1600
+ return chalk.red;
1601
+ case 'revoked':
1602
+ return chalk.gray;
1603
+ default:
1604
+ return chalk.white;
1605
+ }
1606
+ }
1607
+ /**
1608
+ * Read a single line of input from stdin. No fancy framing — `readline`
1609
+ * is sufficient for the install confirmation-code prompt and the
1610
+ * revoke yes/no.
1611
+ */
1612
+ async function promptLine(prompt) {
1613
+ const readline = await import('readline');
1614
+ return new Promise((resolve) => {
1615
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1616
+ rl.question(prompt, (answer) => {
1617
+ rl.close();
1618
+ resolve(answer);
1619
+ });
1620
+ });
1621
+ }
1233
1622
  // ── Worksheets ──────────────────────────────────────────────
1234
1623
  const ws = program.command('worksheets').alias('ws').description('Worksheet commands');
1235
1624
  ws
@@ -3557,6 +3946,24 @@ pipelines
3557
3946
  console.log(` ID: ${inst.id}`);
3558
3947
  }
3559
3948
  catch (err) {
3949
+ // F16 — Role-aware Connection-unavailable error (tech spec §5.14).
3950
+ // The pipeline-run path returns a structured payload when the
3951
+ // Project's Connection is missing/revoked/suspended/uninstalled.
3952
+ // Branch copy on the caller's role from the cached /auth/me; admin
3953
+ // sees a settings URL, member sees "Ask your admin" without one.
3954
+ if (err instanceof ApiError && isProjectConnectionUnavailable(err.body)) {
3955
+ const role = resolveActiveOrgRole();
3956
+ const lines = renderProjectConnectionUnavailableCopy(err.body, role);
3957
+ console.error('');
3958
+ console.error(chalk.red(lines.title));
3959
+ if (lines.body)
3960
+ console.error(chalk.gray(lines.body));
3961
+ if (lines.ctaUrl) {
3962
+ console.error('');
3963
+ console.error(chalk.gray(` ${lines.ctaUrl}`));
3964
+ }
3965
+ process.exit(1);
3966
+ }
3560
3967
  console.error(chalk.red(err.message));
3561
3968
  process.exit(1);
3562
3969
  }
@@ -4160,6 +4567,22 @@ program
4160
4567
  console.log(` Status: ${exec.status}`);
4161
4568
  }
4162
4569
  catch (err) {
4570
+ // F16 — Same role-aware Connection-unavailable handling as `pl
4571
+ // create` (tech spec §5.14). The launcher rejects executions when
4572
+ // the Project's Connection is missing/unhealthy.
4573
+ if (err instanceof ApiError && isProjectConnectionUnavailable(err.body)) {
4574
+ const role = resolveActiveOrgRole();
4575
+ const lines = renderProjectConnectionUnavailableCopy(err.body, role);
4576
+ console.error('');
4577
+ console.error(chalk.red(lines.title));
4578
+ if (lines.body)
4579
+ console.error(chalk.gray(lines.body));
4580
+ if (lines.ctaUrl) {
4581
+ console.error('');
4582
+ console.error(chalk.gray(` ${lines.ctaUrl}`));
4583
+ }
4584
+ process.exit(1);
4585
+ }
4163
4586
  console.error(chalk.red(err.message));
4164
4587
  process.exit(1);
4165
4588
  }