@formigio/fazemos-cli 0.7.0 → 0.8.1
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/connectionErrorCopy.d.ts +46 -0
- package/dist/connectionErrorCopy.js +102 -0
- package/dist/connectionErrorCopy.js.map +1 -0
- package/dist/index.js +424 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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';
|
|
@@ -551,6 +552,7 @@ orgs
|
|
|
551
552
|
}
|
|
552
553
|
const data = await api('POST', `/api/organizations/${orgId}/members`, {
|
|
553
554
|
displayName: opts.name,
|
|
555
|
+
memberType: 'agent',
|
|
554
556
|
role: opts.role,
|
|
555
557
|
});
|
|
556
558
|
console.log(chalk.green(`Created agent: ${data.member.display_name}`));
|
|
@@ -799,6 +801,26 @@ function requireActiveOrgOrExit() {
|
|
|
799
801
|
}
|
|
800
802
|
return orgId;
|
|
801
803
|
}
|
|
804
|
+
/**
|
|
805
|
+
* F16 — Resolve the caller's role in the active Org from the cached
|
|
806
|
+
* /auth/me response. Used to branch the `PROJECT_CONNECTION_UNAVAILABLE`
|
|
807
|
+
* error copy (tech spec §5.14) — admin variant includes a settings URL,
|
|
808
|
+
* member variant does not.
|
|
809
|
+
*
|
|
810
|
+
* Falls back to 'member' if the cache is empty or the active Org isn't
|
|
811
|
+
* present in it. Conservative — better to render the no-CTA copy than
|
|
812
|
+
* mislead a non-admin into clicking a settings link they can't act on.
|
|
813
|
+
*/
|
|
814
|
+
function resolveActiveOrgRole() {
|
|
815
|
+
const orgId = getActiveOrgId();
|
|
816
|
+
if (!orgId)
|
|
817
|
+
return 'member';
|
|
818
|
+
const org = findOrgById(orgId);
|
|
819
|
+
const role = org?.role;
|
|
820
|
+
if (role === 'owner' || role === 'admin' || role === 'member')
|
|
821
|
+
return role;
|
|
822
|
+
return 'member';
|
|
823
|
+
}
|
|
802
824
|
notifications
|
|
803
825
|
.command('get')
|
|
804
826
|
.description('Show current notification config (events enabled, webhook source)')
|
|
@@ -1230,6 +1252,374 @@ projects
|
|
|
1230
1252
|
process.exit(1);
|
|
1231
1253
|
}
|
|
1232
1254
|
});
|
|
1255
|
+
// ── F16 — projects set-connection (binds a Connection to a project) ─
|
|
1256
|
+
projects
|
|
1257
|
+
.command('set-connection')
|
|
1258
|
+
.description('Bind a GitHub Connection to a project. Pass "none" to unbind. Owner/admin only.')
|
|
1259
|
+
.argument('<slug>', 'Project slug')
|
|
1260
|
+
.argument('<connection>', 'Connection ID, or "none" to unbind')
|
|
1261
|
+
.action(async (slug, connection) => {
|
|
1262
|
+
try {
|
|
1263
|
+
const orgId = requireActiveOrgOrExit();
|
|
1264
|
+
let project = findProjectBySlug(orgId, slug);
|
|
1265
|
+
if (!project) {
|
|
1266
|
+
await refreshAuthMeCache();
|
|
1267
|
+
project = findProjectBySlug(orgId, slug);
|
|
1268
|
+
}
|
|
1269
|
+
if (!project) {
|
|
1270
|
+
console.error(chalk.red(`Unknown project: ${slug}`));
|
|
1271
|
+
console.log(chalk.gray('Run: fazemos projects list'));
|
|
1272
|
+
process.exit(1);
|
|
1273
|
+
}
|
|
1274
|
+
const githubConnectionId = connection === 'none' ? null : connection;
|
|
1275
|
+
const data = await api('PATCH', `/api/organizations/${orgId}/projects/${project.id}`, { githubConnectionId }, { noProjectHeader: true });
|
|
1276
|
+
const p = data.project;
|
|
1277
|
+
if (githubConnectionId === null) {
|
|
1278
|
+
console.log(chalk.green(`✓ ${p.name} (${p.slug}) — GitHub connection cleared`));
|
|
1279
|
+
}
|
|
1280
|
+
else {
|
|
1281
|
+
// The detail endpoint may not return the connection's display
|
|
1282
|
+
// name; show the bound id and let the user run `fazemos
|
|
1283
|
+
// connections show <id>` for full details.
|
|
1284
|
+
console.log(chalk.green(`✓ ${p.name} (${p.slug}) is now using connection ${githubConnectionId}`));
|
|
1285
|
+
}
|
|
1286
|
+
invalidateAuthMeCache();
|
|
1287
|
+
}
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
if (err instanceof ApiError) {
|
|
1290
|
+
if (err.code === 'CONNECTION_NOT_ACTIVE') {
|
|
1291
|
+
const body = (err.body ?? {});
|
|
1292
|
+
const reasonLabel = body.reason ?? 'not active';
|
|
1293
|
+
console.error(chalk.red(`Connection is ${reasonLabel} and can't be bound to a project.`));
|
|
1294
|
+
console.log(chalk.gray('Pick a different connection: fazemos connections list'));
|
|
1295
|
+
process.exit(1);
|
|
1296
|
+
}
|
|
1297
|
+
if (err.code === 'CONNECTION_NOT_FOUND') {
|
|
1298
|
+
console.error(chalk.red('Connection not found.'));
|
|
1299
|
+
console.log(chalk.gray('List your connections: fazemos connections list'));
|
|
1300
|
+
process.exit(1);
|
|
1301
|
+
}
|
|
1302
|
+
if (err.code === 'CONNECTION_CROSS_ORG') {
|
|
1303
|
+
console.error(chalk.red("That connection belongs to a different organization."));
|
|
1304
|
+
process.exit(1);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
console.error(chalk.red(err.message));
|
|
1308
|
+
process.exit(1);
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
// ── F16 — Connections ───────────────────────────────────────
|
|
1312
|
+
const connections = program.command('connections').alias('conn').description('GitHub Connection commands');
|
|
1313
|
+
/**
|
|
1314
|
+
* `fazemos connections list` — list active+pending Connections in the
|
|
1315
|
+
* active Org. Output is tabular per Sage UX §8.3.
|
|
1316
|
+
*/
|
|
1317
|
+
connections
|
|
1318
|
+
.command('list')
|
|
1319
|
+
.description('List GitHub Connections in the active organization')
|
|
1320
|
+
.option('-s, --status <status>', 'Filter: active (default), all, pending, suspended, revoked, uninstalled', 'active')
|
|
1321
|
+
.action(async (opts) => {
|
|
1322
|
+
try {
|
|
1323
|
+
const orgId = requireActiveOrgOrExit();
|
|
1324
|
+
const data = await api('GET', `/api/organizations/${orgId}/github/connections?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
|
|
1325
|
+
const list = data.connections ?? [];
|
|
1326
|
+
if (list.length === 0) {
|
|
1327
|
+
const orgName = findOrgById(orgId)?.name ?? orgId;
|
|
1328
|
+
console.log(chalk.yellow(`No GitHub connections in ${orgName}.`));
|
|
1329
|
+
console.log(chalk.gray('Add one with: fazemos connections install'));
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const orgName = findOrgById(orgId)?.name ?? orgId;
|
|
1333
|
+
console.log(chalk.cyan(`GitHub connections in ${orgName}:`));
|
|
1334
|
+
console.log('');
|
|
1335
|
+
for (const c of list) {
|
|
1336
|
+
const statusColor = pickStatusColor(c.status);
|
|
1337
|
+
const projects = c.projectCount == null ? '—' : `${c.projectCount} ${c.projectCount === 1 ? 'project' : 'projects'}`;
|
|
1338
|
+
const login = c.githubAccountLogin ?? chalk.gray('—');
|
|
1339
|
+
console.log(` ${chalk.cyan(c.name)} ${chalk.gray(login)} ${statusColor(c.status)} ${chalk.gray(projects)}`);
|
|
1340
|
+
console.log(chalk.gray(` ID: ${c.id}`));
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
console.error(chalk.red(err.message));
|
|
1345
|
+
process.exit(1);
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
/**
|
|
1349
|
+
* `fazemos connections show <id>` — full detail including repos and
|
|
1350
|
+
* bound projects.
|
|
1351
|
+
*/
|
|
1352
|
+
connections
|
|
1353
|
+
.command('show')
|
|
1354
|
+
.description('Show a GitHub Connection in detail')
|
|
1355
|
+
.argument('<id>', 'Connection ID')
|
|
1356
|
+
.action(async (id) => {
|
|
1357
|
+
try {
|
|
1358
|
+
const orgId = requireActiveOrgOrExit();
|
|
1359
|
+
const data = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
|
|
1360
|
+
const c = data.connection;
|
|
1361
|
+
console.log(chalk.cyan(c.name));
|
|
1362
|
+
console.log(` ID: ${c.id}`);
|
|
1363
|
+
console.log(` GitHub: ${c.githubAccountLogin ?? chalk.gray('— (pending)')}`);
|
|
1364
|
+
if (c.githubAccountType)
|
|
1365
|
+
console.log(` Account type: ${c.githubAccountType}`);
|
|
1366
|
+
const statusColor = pickStatusColor(c.status);
|
|
1367
|
+
console.log(` Status: ${statusColor(c.status)}`);
|
|
1368
|
+
console.log(` Installed: ${c.installedAt ? new Date(c.installedAt).toLocaleString() : '(unknown)'}`);
|
|
1369
|
+
if (c.lastHealthCheckAt) {
|
|
1370
|
+
console.log(` Last checked: ${new Date(c.lastHealthCheckAt).toLocaleString()}`);
|
|
1371
|
+
}
|
|
1372
|
+
if (c.lastUsedAt) {
|
|
1373
|
+
console.log(` Last used: ${new Date(c.lastUsedAt).toLocaleString()}`);
|
|
1374
|
+
}
|
|
1375
|
+
if (c.repositorySelection === 'all') {
|
|
1376
|
+
console.log(` Repos: All repositories${c.githubAccountLogin ? ` in ${c.githubAccountLogin}` : ''}`);
|
|
1377
|
+
}
|
|
1378
|
+
else if (Array.isArray(c.repositories)) {
|
|
1379
|
+
console.log(` Repos: ${c.repositories.length}${c.repositoriesTruncated ? ' (showing 100)' : ''}`);
|
|
1380
|
+
for (const r of c.repositories) {
|
|
1381
|
+
console.log(chalk.gray(` · ${r.fullName}`));
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (Array.isArray(c.boundProjects) && c.boundProjects.length > 0) {
|
|
1385
|
+
console.log('');
|
|
1386
|
+
console.log(chalk.cyan('Projects using this connection:'));
|
|
1387
|
+
for (const p of c.boundProjects) {
|
|
1388
|
+
console.log(` · ${p.name} (${p.slug})`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
catch (err) {
|
|
1393
|
+
if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
|
|
1394
|
+
console.error(chalk.red('Connection not found.'));
|
|
1395
|
+
console.log(chalk.gray('List your connections: fazemos connections list'));
|
|
1396
|
+
process.exit(1);
|
|
1397
|
+
}
|
|
1398
|
+
console.error(chalk.red(err.message));
|
|
1399
|
+
process.exit(1);
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
/**
|
|
1403
|
+
* `fazemos connections install` — print-URL + confirmation-code flow per
|
|
1404
|
+
* Sage UX §8.1. Two API calls: mint URL → exchange code.
|
|
1405
|
+
*/
|
|
1406
|
+
connections
|
|
1407
|
+
.command('install')
|
|
1408
|
+
.description('Add a GitHub Connection. Prints an install URL and waits for a confirmation code.')
|
|
1409
|
+
.action(async () => {
|
|
1410
|
+
try {
|
|
1411
|
+
const orgId = requireActiveOrgOrExit();
|
|
1412
|
+
const orgName = findOrgById(orgId)?.name ?? orgId;
|
|
1413
|
+
const mintData = await api('POST', `/api/organizations/${orgId}/github/connections/install-url`, { source: 'cli', returnTo: null }, { noProjectHeader: true });
|
|
1414
|
+
console.log('');
|
|
1415
|
+
console.log(`To add a GitHub connection to ${chalk.cyan(orgName)}:`);
|
|
1416
|
+
console.log('');
|
|
1417
|
+
console.log(' 1. Open this URL in your browser (expires in 10 minutes):');
|
|
1418
|
+
console.log(` ${chalk.cyan(mintData.url)}`);
|
|
1419
|
+
console.log('');
|
|
1420
|
+
console.log(' 2. Install the Fazemos App on your GitHub org or account.');
|
|
1421
|
+
console.log('');
|
|
1422
|
+
console.log(' 3. After installing, you\'ll see a confirmation code on the page.');
|
|
1423
|
+
console.log(' Paste it here:');
|
|
1424
|
+
console.log('');
|
|
1425
|
+
const code = await promptLine(' Code: ');
|
|
1426
|
+
if (!code) {
|
|
1427
|
+
console.error(chalk.red('No code provided. Aborted.'));
|
|
1428
|
+
process.exit(1);
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
const exchangeData = await api('POST', '/api/github/connections/exchange-code', { code: code.trim() }, { noProjectHeader: true });
|
|
1432
|
+
const c = exchangeData.connection;
|
|
1433
|
+
console.log('');
|
|
1434
|
+
console.log(chalk.green(` ✓ Connected: ${c.name}${c.githubAccountLogin ? ` (${c.githubAccountLogin})` : ''}`));
|
|
1435
|
+
if (c.repositorySelection === 'all') {
|
|
1436
|
+
console.log(chalk.gray(` Repos: All repositories${c.githubAccountLogin ? ` in ${c.githubAccountLogin}` : ''}`));
|
|
1437
|
+
}
|
|
1438
|
+
console.log(chalk.gray(` Connection ID: ${c.id}`));
|
|
1439
|
+
console.log('');
|
|
1440
|
+
console.log(chalk.gray(' To use this in a project:'));
|
|
1441
|
+
console.log(chalk.gray(` fazemos projects set-connection <project-slug> ${c.id}`));
|
|
1442
|
+
invalidateAuthMeCache();
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
if (err instanceof ApiError) {
|
|
1446
|
+
if (err.code === 'CODE_EXPIRED') {
|
|
1447
|
+
console.error(chalk.red(' ✗ That code expired. Codes are valid for 10 minutes.'));
|
|
1448
|
+
console.log(chalk.gray(' Try again: fazemos connections install'));
|
|
1449
|
+
process.exit(1);
|
|
1450
|
+
}
|
|
1451
|
+
if (err.code === 'CODE_ALREADY_USED') {
|
|
1452
|
+
console.error(chalk.red(' ✗ That code was already used.'));
|
|
1453
|
+
console.log(chalk.gray(' Start over: fazemos connections install'));
|
|
1454
|
+
process.exit(1);
|
|
1455
|
+
}
|
|
1456
|
+
if (err.code === 'CODE_NOT_FOUND') {
|
|
1457
|
+
console.error(chalk.red(' ✗ That code didn\'t match.'));
|
|
1458
|
+
console.log(chalk.gray(' Double-check it or start over: fazemos connections install'));
|
|
1459
|
+
process.exit(1);
|
|
1460
|
+
}
|
|
1461
|
+
if (err.code === 'CODE_WRONG_USER') {
|
|
1462
|
+
console.error(chalk.red(' ✗ That code was generated by a different user.'));
|
|
1463
|
+
console.log(chalk.gray(' Start over: fazemos connections install'));
|
|
1464
|
+
process.exit(1);
|
|
1465
|
+
}
|
|
1466
|
+
if (err.code === 'CODE_INVALID') {
|
|
1467
|
+
console.error(chalk.red(' ✗ That code doesn\'t look right.'));
|
|
1468
|
+
console.log(chalk.gray(' Start over: fazemos connections install'));
|
|
1469
|
+
process.exit(1);
|
|
1470
|
+
}
|
|
1471
|
+
if (err.code === 'CODE_RATE_LIMITED') {
|
|
1472
|
+
const body = (err.body ?? {});
|
|
1473
|
+
const after = body.retryAfter ? `${body.retryAfter}s` : '60s';
|
|
1474
|
+
console.error(chalk.red(` ✗ Too many code attempts. Try again in ${after}.`));
|
|
1475
|
+
process.exit(1);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
throw err;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
catch (err) {
|
|
1482
|
+
if (err instanceof ApiError && err.code === 'MISSING_GITHUB_CONFIG') {
|
|
1483
|
+
console.error(chalk.red('GitHub App is not configured on this server.'));
|
|
1484
|
+
console.log(chalk.gray('Contact your Fazemos operator.'));
|
|
1485
|
+
process.exit(1);
|
|
1486
|
+
}
|
|
1487
|
+
if (err instanceof ApiError && err.code === 'FORBIDDEN_ROLE') {
|
|
1488
|
+
console.error(chalk.red('Only org owners and admins can add GitHub connections.'));
|
|
1489
|
+
process.exit(1);
|
|
1490
|
+
}
|
|
1491
|
+
console.error(chalk.red(err.message));
|
|
1492
|
+
process.exit(1);
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
/**
|
|
1496
|
+
* `fazemos connections revoke <id>` — disconnect with confirmation
|
|
1497
|
+
* prompt. `--force` skips the prompt for scripted use.
|
|
1498
|
+
*/
|
|
1499
|
+
connections
|
|
1500
|
+
.command('revoke')
|
|
1501
|
+
.alias('disconnect')
|
|
1502
|
+
.description('Disconnect a GitHub Connection (Fazemos-side; does not uninstall the App from GitHub).')
|
|
1503
|
+
.argument('<id>', 'Connection ID')
|
|
1504
|
+
.option('-f, --force', 'Skip the confirmation prompt', false)
|
|
1505
|
+
.action(async (id, opts) => {
|
|
1506
|
+
try {
|
|
1507
|
+
const orgId = requireActiveOrgOrExit();
|
|
1508
|
+
// Fetch the Connection so we can show what we're about to revoke
|
|
1509
|
+
// and how many projects it affects (Sage §8.3 confirmation copy).
|
|
1510
|
+
let connection;
|
|
1511
|
+
try {
|
|
1512
|
+
const detail = await api('GET', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
|
|
1513
|
+
connection = detail.connection;
|
|
1514
|
+
}
|
|
1515
|
+
catch (err) {
|
|
1516
|
+
if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
|
|
1517
|
+
console.error(chalk.red('Connection not found.'));
|
|
1518
|
+
process.exit(1);
|
|
1519
|
+
}
|
|
1520
|
+
throw err;
|
|
1521
|
+
}
|
|
1522
|
+
const boundProjects = connection.boundProjects ?? [];
|
|
1523
|
+
if (!opts.force) {
|
|
1524
|
+
console.log('');
|
|
1525
|
+
console.log(`Disconnect ${chalk.cyan(connection.name)}?`);
|
|
1526
|
+
console.log('');
|
|
1527
|
+
if (boundProjects.length > 0) {
|
|
1528
|
+
const projectList = boundProjects.map(p => p.name).join(', ');
|
|
1529
|
+
console.log(` This connection is used by ${boundProjects.length} ${boundProjects.length === 1 ? 'project' : 'projects'}: ${projectList}.`);
|
|
1530
|
+
console.log(' Pipelines in those projects will fail on GitHub steps until');
|
|
1531
|
+
console.log(' they are bound to a different connection.');
|
|
1532
|
+
console.log('');
|
|
1533
|
+
}
|
|
1534
|
+
else {
|
|
1535
|
+
console.log(' No projects are using this connection.');
|
|
1536
|
+
console.log('');
|
|
1537
|
+
}
|
|
1538
|
+
console.log(chalk.gray(' This does not uninstall the Fazemos App from GitHub.'));
|
|
1539
|
+
console.log('');
|
|
1540
|
+
const answer = await promptLine('Disconnect? [y/N]: ');
|
|
1541
|
+
if (!answer || !/^y/i.test(answer.trim())) {
|
|
1542
|
+
console.log(chalk.gray('Cancelled.'));
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const data = await api('DELETE', `/api/organizations/${orgId}/github/connections/${id}`, undefined, { noProjectHeader: true });
|
|
1547
|
+
console.log(chalk.green(`Disconnected: ${connection.name}`));
|
|
1548
|
+
const affected = data.affectedProjects ?? [];
|
|
1549
|
+
if (affected.length > 0) {
|
|
1550
|
+
console.log(chalk.gray(` ${affected.length} project${affected.length === 1 ? '' : 's'} unbound.`));
|
|
1551
|
+
}
|
|
1552
|
+
invalidateAuthMeCache();
|
|
1553
|
+
}
|
|
1554
|
+
catch (err) {
|
|
1555
|
+
console.error(chalk.red(err.message));
|
|
1556
|
+
process.exit(1);
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
/**
|
|
1560
|
+
* `fazemos connections health-check <id>` — manually verify with
|
|
1561
|
+
* GitHub. Updates status and last_health_check_at.
|
|
1562
|
+
*/
|
|
1563
|
+
connections
|
|
1564
|
+
.command('health-check')
|
|
1565
|
+
.description('Verify a Connection is still healthy on GitHub')
|
|
1566
|
+
.argument('<id>', 'Connection ID')
|
|
1567
|
+
.action(async (id) => {
|
|
1568
|
+
try {
|
|
1569
|
+
const orgId = requireActiveOrgOrExit();
|
|
1570
|
+
const data = await api('POST', `/api/organizations/${orgId}/github/connections/${id}/health-check`, {}, { noProjectHeader: true });
|
|
1571
|
+
const c = data.connection;
|
|
1572
|
+
const statusColor = pickStatusColor(c.status);
|
|
1573
|
+
if (data.changed) {
|
|
1574
|
+
console.log(chalk.yellow(`Status changed: ${c.name} → ${statusColor(c.status)}`));
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
console.log(chalk.green(`✓ ${c.name} — ${statusColor(c.status)}`));
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
catch (err) {
|
|
1581
|
+
if (err instanceof ApiError && err.code === 'CONNECTION_NOT_FOUND') {
|
|
1582
|
+
console.error(chalk.red('Connection not found.'));
|
|
1583
|
+
process.exit(1);
|
|
1584
|
+
}
|
|
1585
|
+
console.error(chalk.red(err.message));
|
|
1586
|
+
process.exit(1);
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
/**
|
|
1590
|
+
* Helper — color a status string per the Sage §2.2 taxonomy.
|
|
1591
|
+
*/
|
|
1592
|
+
function pickStatusColor(status) {
|
|
1593
|
+
switch (status) {
|
|
1594
|
+
case 'active':
|
|
1595
|
+
return chalk.green;
|
|
1596
|
+
case 'pending':
|
|
1597
|
+
return chalk.yellow;
|
|
1598
|
+
case 'suspended':
|
|
1599
|
+
return chalk.yellow;
|
|
1600
|
+
case 'uninstalled':
|
|
1601
|
+
return chalk.red;
|
|
1602
|
+
case 'revoked':
|
|
1603
|
+
return chalk.gray;
|
|
1604
|
+
default:
|
|
1605
|
+
return chalk.white;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Read a single line of input from stdin. No fancy framing — `readline`
|
|
1610
|
+
* is sufficient for the install confirmation-code prompt and the
|
|
1611
|
+
* revoke yes/no.
|
|
1612
|
+
*/
|
|
1613
|
+
async function promptLine(prompt) {
|
|
1614
|
+
const readline = await import('readline');
|
|
1615
|
+
return new Promise((resolve) => {
|
|
1616
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1617
|
+
rl.question(prompt, (answer) => {
|
|
1618
|
+
rl.close();
|
|
1619
|
+
resolve(answer);
|
|
1620
|
+
});
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1233
1623
|
// ── Worksheets ──────────────────────────────────────────────
|
|
1234
1624
|
const ws = program.command('worksheets').alias('ws').description('Worksheet commands');
|
|
1235
1625
|
ws
|
|
@@ -3557,6 +3947,24 @@ pipelines
|
|
|
3557
3947
|
console.log(` ID: ${inst.id}`);
|
|
3558
3948
|
}
|
|
3559
3949
|
catch (err) {
|
|
3950
|
+
// F16 — Role-aware Connection-unavailable error (tech spec §5.14).
|
|
3951
|
+
// The pipeline-run path returns a structured payload when the
|
|
3952
|
+
// Project's Connection is missing/revoked/suspended/uninstalled.
|
|
3953
|
+
// Branch copy on the caller's role from the cached /auth/me; admin
|
|
3954
|
+
// sees a settings URL, member sees "Ask your admin" without one.
|
|
3955
|
+
if (err instanceof ApiError && isProjectConnectionUnavailable(err.body)) {
|
|
3956
|
+
const role = resolveActiveOrgRole();
|
|
3957
|
+
const lines = renderProjectConnectionUnavailableCopy(err.body, role);
|
|
3958
|
+
console.error('');
|
|
3959
|
+
console.error(chalk.red(lines.title));
|
|
3960
|
+
if (lines.body)
|
|
3961
|
+
console.error(chalk.gray(lines.body));
|
|
3962
|
+
if (lines.ctaUrl) {
|
|
3963
|
+
console.error('');
|
|
3964
|
+
console.error(chalk.gray(` ${lines.ctaUrl}`));
|
|
3965
|
+
}
|
|
3966
|
+
process.exit(1);
|
|
3967
|
+
}
|
|
3560
3968
|
console.error(chalk.red(err.message));
|
|
3561
3969
|
process.exit(1);
|
|
3562
3970
|
}
|
|
@@ -4160,6 +4568,22 @@ program
|
|
|
4160
4568
|
console.log(` Status: ${exec.status}`);
|
|
4161
4569
|
}
|
|
4162
4570
|
catch (err) {
|
|
4571
|
+
// F16 — Same role-aware Connection-unavailable handling as `pl
|
|
4572
|
+
// create` (tech spec §5.14). The launcher rejects executions when
|
|
4573
|
+
// the Project's Connection is missing/unhealthy.
|
|
4574
|
+
if (err instanceof ApiError && isProjectConnectionUnavailable(err.body)) {
|
|
4575
|
+
const role = resolveActiveOrgRole();
|
|
4576
|
+
const lines = renderProjectConnectionUnavailableCopy(err.body, role);
|
|
4577
|
+
console.error('');
|
|
4578
|
+
console.error(chalk.red(lines.title));
|
|
4579
|
+
if (lines.body)
|
|
4580
|
+
console.error(chalk.gray(lines.body));
|
|
4581
|
+
if (lines.ctaUrl) {
|
|
4582
|
+
console.error('');
|
|
4583
|
+
console.error(chalk.gray(` ${lines.ctaUrl}`));
|
|
4584
|
+
}
|
|
4585
|
+
process.exit(1);
|
|
4586
|
+
}
|
|
4163
4587
|
console.error(chalk.red(err.message));
|
|
4164
4588
|
process.exit(1);
|
|
4165
4589
|
}
|