@formigio/fazemos-cli 0.6.1 → 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.
- package/dist/api.d.ts +63 -2
- package/dist/api.js +107 -5
- package/dist/api.js.map +1 -1
- package/dist/config.d.ts +90 -0
- package/dist/config.js +90 -0
- package/dist/config.js.map +1 -1
- package/dist/connectionErrorCopy.d.ts +46 -0
- package/dist/connectionErrorCopy.js +102 -0
- package/dist/connectionErrorCopy.js.map +1 -0
- package/dist/index.js +851 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironment, hasEnvironments
|
|
4
|
+
import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironment, hasEnvironments,
|
|
5
|
+
// F15 — project context helpers
|
|
6
|
+
getActiveProjectId, setActiveProjectId, clearActiveProjectId, findProjectBySlug, findProjectById, findOrgById, } from './config.js';
|
|
5
7
|
import { login, signup, confirmSignup, adminLogin } from './auth.js';
|
|
6
|
-
import { api, ApiError } from './api.js';
|
|
8
|
+
import { api, ApiError, refreshAuthMeCache, invalidateAuthMeCache } from './api.js';
|
|
9
|
+
import { isProjectConnectionUnavailable, renderProjectConnectionUnavailableCopy, } from './connectionErrorCopy.js';
|
|
7
10
|
import { execSync } from 'child_process';
|
|
8
11
|
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
|
|
9
12
|
import { fileURLToPath } from 'url';
|
|
@@ -249,13 +252,47 @@ auth
|
|
|
249
252
|
// ── Whoami ──────────────────────────────────────────────────
|
|
250
253
|
program
|
|
251
254
|
.command('whoami')
|
|
252
|
-
.description('Show current user and
|
|
255
|
+
.description('Show current user, org, and project context')
|
|
253
256
|
.action(async () => {
|
|
254
257
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
// Force-refresh the /auth/me cache on whoami so subsequent slug
|
|
259
|
+
// resolutions (e.g., `projects switch vahmos`) see the fresh state
|
|
260
|
+
// without a second round-trip.
|
|
261
|
+
const data = await refreshAuthMeCache();
|
|
262
|
+
if (!data) {
|
|
263
|
+
// Fall back to a direct call if refresh failed silently.
|
|
264
|
+
const direct = await api('GET', '/auth/me', undefined, { noProjectHeader: true });
|
|
265
|
+
console.log(` User: ${chalk.cyan(direct.user.email)}`);
|
|
266
|
+
console.log(` Member: ${direct.member.displayName} (${direct.member.role})`);
|
|
267
|
+
console.log(` Org: ${direct.member.orgId}`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
console.log(` User: ${chalk.cyan(data.user.email)}`);
|
|
271
|
+
console.log(` Member: ${data.member.displayName} (${data.member.role})`);
|
|
272
|
+
const activeOrgId = getActiveOrgId() ?? data.activeOrgId;
|
|
273
|
+
const activeOrg = data.orgs.find(o => o.id === activeOrgId);
|
|
274
|
+
if (activeOrg) {
|
|
275
|
+
console.log(` Org: ${chalk.cyan(activeOrg.name)} (${activeOrg.slug})`);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
console.log(` Org: ${activeOrgId}`);
|
|
279
|
+
}
|
|
280
|
+
const activeProjectId = getActiveProjectId();
|
|
281
|
+
if (activeProjectId && activeOrg) {
|
|
282
|
+
const activeProject = activeOrg.projects.find(p => p.id === activeProjectId);
|
|
283
|
+
if (activeProject) {
|
|
284
|
+
console.log(` Project: ${chalk.cyan(activeProject.name)} (${activeProject.slug})`);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
console.log(` Project: ${activeProjectId}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else if (activeOrg && activeOrg.projects.length === 0) {
|
|
291
|
+
console.log(` Project: ${chalk.gray('(none — create one with: fazemos projects create <slug> --name <n>)')}`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
console.log(` Project: ${chalk.gray('(none set — fazemos projects switch <slug>)')}`);
|
|
295
|
+
}
|
|
259
296
|
}
|
|
260
297
|
catch (err) {
|
|
261
298
|
console.error(chalk.red(err.message));
|
|
@@ -269,7 +306,7 @@ orgs
|
|
|
269
306
|
.description('List your organizations')
|
|
270
307
|
.action(async () => {
|
|
271
308
|
try {
|
|
272
|
-
const data = await api('GET', '/api/organizations/mine');
|
|
309
|
+
const data = await api('GET', '/api/organizations/mine', undefined, { noProjectHeader: true });
|
|
273
310
|
if (data.organizations.length === 0) {
|
|
274
311
|
console.log(chalk.yellow('No organizations. Create one with: fazemos orgs create'));
|
|
275
312
|
return;
|
|
@@ -313,11 +350,11 @@ orgs
|
|
|
313
350
|
});
|
|
314
351
|
orgs
|
|
315
352
|
.command('switch')
|
|
316
|
-
.description('Switch active organization')
|
|
353
|
+
.description('Switch active organization. Restores the last active project for this org from config, or clears it if none has been used here before.')
|
|
317
354
|
.argument('<slug>', 'Organization slug')
|
|
318
355
|
.action(async (slug) => {
|
|
319
356
|
try {
|
|
320
|
-
const data = await api('GET', '/api/organizations/mine');
|
|
357
|
+
const data = await api('GET', '/api/organizations/mine', undefined, { noProjectHeader: true });
|
|
321
358
|
const org = data.organizations.find((o) => o.slug === slug);
|
|
322
359
|
if (!org) {
|
|
323
360
|
console.error(chalk.red(`No organization with slug "${slug}"`));
|
|
@@ -329,6 +366,36 @@ orgs
|
|
|
329
366
|
}
|
|
330
367
|
setActiveOrgId(org.id);
|
|
331
368
|
console.log(chalk.green(`Switched to ${org.name} (${org.slug})`));
|
|
369
|
+
// F15 — refresh /auth/me so the next `fazemos projects list` (and any
|
|
370
|
+
// slug→id resolutions) see the project set for the new org. Without
|
|
371
|
+
// this the cached authMeCache is effectively stale and slug lookups
|
|
372
|
+
// force an extra network call each time.
|
|
373
|
+
invalidateAuthMeCache();
|
|
374
|
+
await refreshAuthMeCache();
|
|
375
|
+
// Report the restored active project, if any. Matches UX §1.8
|
|
376
|
+
// "Switching Org" — restore last-used project for this org or fall
|
|
377
|
+
// back to the first project alphabetically.
|
|
378
|
+
const activeProjectId = getActiveProjectId();
|
|
379
|
+
if (activeProjectId) {
|
|
380
|
+
const restored = findProjectById(org.id, activeProjectId);
|
|
381
|
+
if (restored) {
|
|
382
|
+
console.log(chalk.cyan(`Restored project: ${restored.name} (${restored.slug})`));
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
// Stored project id is no longer in this org (archived?). Clear it.
|
|
386
|
+
clearActiveProjectId(org.id);
|
|
387
|
+
console.log(chalk.gray('Previously active project for this org is no longer available.'));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const orgCache = findOrgById(org.id);
|
|
392
|
+
if (orgCache && orgCache.projects.length === 0) {
|
|
393
|
+
console.log(chalk.gray('No projects in this org yet. Create one with: fazemos projects create <slug> --name <n>'));
|
|
394
|
+
}
|
|
395
|
+
else if (orgCache && orgCache.projects.length > 0) {
|
|
396
|
+
console.log(chalk.gray(`No active project set. Run: fazemos projects switch <slug>`));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
332
399
|
}
|
|
333
400
|
catch (err) {
|
|
334
401
|
console.error(chalk.red(err.message));
|
|
@@ -733,6 +800,26 @@ function requireActiveOrgOrExit() {
|
|
|
733
800
|
}
|
|
734
801
|
return orgId;
|
|
735
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
|
+
}
|
|
736
823
|
notifications
|
|
737
824
|
.command('get')
|
|
738
825
|
.description('Show current notification config (events enabled, webhook source)')
|
|
@@ -848,15 +935,701 @@ notifications
|
|
|
848
935
|
process.exit(1);
|
|
849
936
|
}
|
|
850
937
|
});
|
|
938
|
+
// ── F15 — Scoped-command helpers ────────────────────────────
|
|
939
|
+
// Shared helpers for commands whose API calls require a Project context.
|
|
940
|
+
// Keeps the two new flags (--project <slug>, --all-projects) and the
|
|
941
|
+
// uniform missing-project error rendering in one place so every scoped
|
|
942
|
+
// command uses the exact same shape.
|
|
943
|
+
/**
|
|
944
|
+
* Extract ApiOptions (projectSlug + allProjects) from a commander opts
|
|
945
|
+
* bag. Call this inside any `.action()` that forwards to `api()`.
|
|
946
|
+
*/
|
|
947
|
+
function projectOpts(opts) {
|
|
948
|
+
return {
|
|
949
|
+
projectSlug: opts.project,
|
|
950
|
+
allProjects: opts.allProjects,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Uniform error handler for scoped commands. When the API emits
|
|
955
|
+
* MISSING_PROJECT_CONTEXT (§7.3.1), the api helper has already re-shaped
|
|
956
|
+
* the message to "requirement missing: project". This helper prints that
|
|
957
|
+
* as the error AND appends the standard three-option hint block per KD9.
|
|
958
|
+
*
|
|
959
|
+
* All other errors pass through with their original message (preserving
|
|
960
|
+
* PROJECT_NOT_FOUND, PROJECT_MISMATCH, VALIDATION_ERROR wording) — the
|
|
961
|
+
* CLI's job is to be a thin validator, not to re-author API errors.
|
|
962
|
+
*/
|
|
963
|
+
function handleScopedError(err) {
|
|
964
|
+
if (err instanceof ApiError && err.code === 'MISSING_PROJECT_CONTEXT') {
|
|
965
|
+
console.error(chalk.red('Error: requirement missing: project'));
|
|
966
|
+
console.error('');
|
|
967
|
+
console.error(chalk.gray('Set one with: fazemos projects switch <slug>'));
|
|
968
|
+
console.error(chalk.gray('Or pass: --project <slug>'));
|
|
969
|
+
console.error(chalk.gray('Or view all: --all-projects'));
|
|
970
|
+
process.exit(1);
|
|
971
|
+
}
|
|
972
|
+
if (err instanceof Error) {
|
|
973
|
+
console.error(chalk.red(err.message));
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
console.error(chalk.red(String(err)));
|
|
977
|
+
}
|
|
978
|
+
process.exit(1);
|
|
979
|
+
}
|
|
980
|
+
// ── F15 — Projects ──────────────────────────────────────────
|
|
981
|
+
const projects = program.command('projects').alias('proj').description('Project commands');
|
|
982
|
+
projects
|
|
983
|
+
.command('list')
|
|
984
|
+
.description('List projects in the active organization')
|
|
985
|
+
.option('-s, --status <status>', 'Filter by status: active (default), archived, or all', 'active')
|
|
986
|
+
.action(async (opts) => {
|
|
987
|
+
try {
|
|
988
|
+
const orgId = requireActiveOrgOrExit();
|
|
989
|
+
const data = await api('GET', `/api/organizations/${orgId}/projects?status=${encodeURIComponent(opts.status)}`, undefined, { noProjectHeader: true });
|
|
990
|
+
if (!data.projects || data.projects.length === 0) {
|
|
991
|
+
if (opts.status === 'active') {
|
|
992
|
+
console.log(chalk.yellow('No active projects in this organization.'));
|
|
993
|
+
console.log(chalk.gray('Create one with: fazemos projects create <slug> --name <name>'));
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
console.log(chalk.yellow(`No ${opts.status} projects.`));
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const activeProjectId = getActiveProjectId();
|
|
1001
|
+
for (const p of data.projects) {
|
|
1002
|
+
const active = p.id === activeProjectId ? chalk.green(' ✓') : '';
|
|
1003
|
+
const status = p.status === 'archived' ? chalk.gray(' [archived]') : '';
|
|
1004
|
+
const activity = p.lastActivityAt
|
|
1005
|
+
? chalk.gray(` — last active ${formatRelative(new Date(p.lastActivityAt))}`)
|
|
1006
|
+
: '';
|
|
1007
|
+
console.log(` ${chalk.cyan(p.name)} (${p.slug})${active}${status}${activity}`);
|
|
1008
|
+
if (p.stats) {
|
|
1009
|
+
const stats = [];
|
|
1010
|
+
if (p.stats.activePipelines)
|
|
1011
|
+
stats.push(`${p.stats.activePipelines} pipelines`);
|
|
1012
|
+
if (p.stats.worksheets)
|
|
1013
|
+
stats.push(`${p.stats.worksheets} worksheets`);
|
|
1014
|
+
if (p.stats.templates)
|
|
1015
|
+
stats.push(`${p.stats.templates} templates`);
|
|
1016
|
+
if (stats.length)
|
|
1017
|
+
console.log(chalk.gray(` ${stats.join(' · ')}`));
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
console.error(chalk.red(err.message));
|
|
1023
|
+
process.exit(1);
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
projects
|
|
1027
|
+
.command('create')
|
|
1028
|
+
.description('Create a new project in the active organization. Owner/admin only. Slug is immutable after creation — pick carefully.')
|
|
1029
|
+
.argument('<slug>', 'URL slug (lowercase letters, numbers, hyphens; 1-32 chars). Used in URLs and CLI config — cannot be changed.')
|
|
1030
|
+
.requiredOption('-n, --name <name>', 'Human-readable name (max 200 chars)')
|
|
1031
|
+
.option('-d, --description <desc>', 'Optional description (max 2000 chars)')
|
|
1032
|
+
.action(async (slug, opts) => {
|
|
1033
|
+
try {
|
|
1034
|
+
const orgId = requireActiveOrgOrExit();
|
|
1035
|
+
const body = {
|
|
1036
|
+
slug,
|
|
1037
|
+
name: opts.name,
|
|
1038
|
+
};
|
|
1039
|
+
if (opts.description)
|
|
1040
|
+
body.description = opts.description;
|
|
1041
|
+
const data = await api('POST', `/api/organizations/${orgId}/projects`, body, { noProjectHeader: true });
|
|
1042
|
+
const p = data.project;
|
|
1043
|
+
console.log(chalk.green(`Created project: ${p.name} (${p.slug})`));
|
|
1044
|
+
console.log(` ID: ${p.id}`);
|
|
1045
|
+
console.log(` Org: ${orgId}`);
|
|
1046
|
+
if (p.description)
|
|
1047
|
+
console.log(` Desc: ${p.description}`);
|
|
1048
|
+
// Invalidate cached /auth/me and set as active — a fresh project is
|
|
1049
|
+
// usually created immediately before scoped work on it, so auto-
|
|
1050
|
+
// switching saves an extra command. Matches the spec's post-create
|
|
1051
|
+
// success path (UX §2.1 toast "You're in.").
|
|
1052
|
+
invalidateAuthMeCache();
|
|
1053
|
+
setActiveProjectId(orgId, p.id);
|
|
1054
|
+
console.log(chalk.cyan(`Switched active project to ${p.slug}.`));
|
|
1055
|
+
}
|
|
1056
|
+
catch (err) {
|
|
1057
|
+
if (err instanceof ApiError && err.code === 'SLUG_CONFLICT') {
|
|
1058
|
+
console.error(chalk.red(`Error: "${slug}" is already taken in this organization.`));
|
|
1059
|
+
console.log(chalk.gray('Pick a different slug or use: fazemos projects list'));
|
|
1060
|
+
process.exit(1);
|
|
1061
|
+
}
|
|
1062
|
+
console.error(chalk.red(err.message));
|
|
1063
|
+
process.exit(1);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
projects
|
|
1067
|
+
.command('show')
|
|
1068
|
+
.description('Show project details by slug. Resolves against the active organization.')
|
|
1069
|
+
.argument('<slug>', 'Project slug')
|
|
1070
|
+
.action(async (slug) => {
|
|
1071
|
+
try {
|
|
1072
|
+
const orgId = requireActiveOrgOrExit();
|
|
1073
|
+
// Resolve slug -> id via cached /auth/me; refresh once on miss.
|
|
1074
|
+
let project = findProjectBySlug(orgId, slug);
|
|
1075
|
+
if (!project) {
|
|
1076
|
+
await refreshAuthMeCache();
|
|
1077
|
+
project = findProjectBySlug(orgId, slug);
|
|
1078
|
+
}
|
|
1079
|
+
if (!project) {
|
|
1080
|
+
console.error(chalk.red(`Unknown project: ${slug}`));
|
|
1081
|
+
console.log(chalk.gray('Run: fazemos projects list'));
|
|
1082
|
+
process.exit(1);
|
|
1083
|
+
}
|
|
1084
|
+
const data = await api('GET', `/api/organizations/${orgId}/projects/${project.id}`, undefined, { noProjectHeader: true });
|
|
1085
|
+
const p = data.project;
|
|
1086
|
+
console.log(chalk.cyan(p.name));
|
|
1087
|
+
console.log(` Slug: ${p.slug}`);
|
|
1088
|
+
console.log(` ID: ${p.id}`);
|
|
1089
|
+
console.log(` Status: ${p.status === 'archived' ? chalk.gray('archived') : chalk.green('active')}`);
|
|
1090
|
+
if (p.description)
|
|
1091
|
+
console.log(` Description: ${p.description}`);
|
|
1092
|
+
console.log(` Created: ${p.createdAt ? new Date(p.createdAt).toLocaleString() : '(unknown)'}`);
|
|
1093
|
+
if (p.archivedAt)
|
|
1094
|
+
console.log(` Archived at: ${new Date(p.archivedAt).toLocaleString()}`);
|
|
1095
|
+
if (p.stats) {
|
|
1096
|
+
console.log('');
|
|
1097
|
+
console.log(chalk.cyan('Activity:'));
|
|
1098
|
+
console.log(` Active pipelines: ${p.stats.activePipelines ?? 0}`);
|
|
1099
|
+
console.log(` Worksheets: ${p.stats.worksheets ?? 0}`);
|
|
1100
|
+
console.log(` Templates: ${p.stats.templates ?? 0}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
console.error(chalk.red(err.message));
|
|
1105
|
+
process.exit(1);
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
projects
|
|
1109
|
+
.command('switch')
|
|
1110
|
+
.description('Switch active project. Persists in ~/.fazemos/config.json as the active project for the current org.')
|
|
1111
|
+
.argument('<slug>', 'Project slug')
|
|
1112
|
+
.action(async (slug) => {
|
|
1113
|
+
try {
|
|
1114
|
+
const orgId = requireActiveOrgOrExit();
|
|
1115
|
+
let project = findProjectBySlug(orgId, slug);
|
|
1116
|
+
if (!project) {
|
|
1117
|
+
// Slug-miss cache refresh per §8.3.
|
|
1118
|
+
await refreshAuthMeCache();
|
|
1119
|
+
project = findProjectBySlug(orgId, slug);
|
|
1120
|
+
}
|
|
1121
|
+
if (!project) {
|
|
1122
|
+
console.error(chalk.red(`Unknown project: ${slug}`));
|
|
1123
|
+
console.log(chalk.gray('Run: fazemos projects list'));
|
|
1124
|
+
process.exit(1);
|
|
1125
|
+
}
|
|
1126
|
+
setActiveProjectId(orgId, project.id);
|
|
1127
|
+
const org = findOrgById(orgId);
|
|
1128
|
+
const orgName = org?.name ?? orgId;
|
|
1129
|
+
console.log(chalk.green(`Switched to project: ${project.name} (${project.slug}) in ${orgName}`));
|
|
1130
|
+
}
|
|
1131
|
+
catch (err) {
|
|
1132
|
+
console.error(chalk.red(err.message));
|
|
1133
|
+
process.exit(1);
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
projects
|
|
1137
|
+
.command('archive')
|
|
1138
|
+
.description('Archive a project. Puts it in read-only mode; existing work is preserved. Owner/admin only. Blocked if any pipeline is currently running — wait for it to finish or cancel it first.')
|
|
1139
|
+
.argument('<slug>', 'Project slug')
|
|
1140
|
+
.action(async (slug) => {
|
|
1141
|
+
try {
|
|
1142
|
+
const orgId = requireActiveOrgOrExit();
|
|
1143
|
+
let project = findProjectBySlug(orgId, slug);
|
|
1144
|
+
if (!project) {
|
|
1145
|
+
await refreshAuthMeCache();
|
|
1146
|
+
project = findProjectBySlug(orgId, slug);
|
|
1147
|
+
}
|
|
1148
|
+
if (!project) {
|
|
1149
|
+
console.error(chalk.red(`Unknown project: ${slug}`));
|
|
1150
|
+
process.exit(1);
|
|
1151
|
+
}
|
|
1152
|
+
const data = await api('POST', `/api/organizations/${orgId}/projects/${project.id}/archive`, {}, { noProjectHeader: true });
|
|
1153
|
+
console.log(chalk.green(`Archived: ${data.project.name} (${data.project.slug})`));
|
|
1154
|
+
invalidateAuthMeCache();
|
|
1155
|
+
// If this was the active project, clear it — subsequent scoped
|
|
1156
|
+
// commands will emit "requirement missing: project" per KD9.
|
|
1157
|
+
if (getActiveProjectId() === project.id) {
|
|
1158
|
+
clearActiveProjectId(orgId);
|
|
1159
|
+
console.log(chalk.cyan('Active project cleared. Run: fazemos projects switch <slug>'));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
catch (err) {
|
|
1163
|
+
if (err instanceof ApiError && err.code === 'PROJECT_HAS_RUNNING_PIPELINES') {
|
|
1164
|
+
console.error(chalk.red(`Can't archive ${slug} yet — pipelines are still running.`));
|
|
1165
|
+
const body = err.body;
|
|
1166
|
+
if (body?.runningPipelines?.length) {
|
|
1167
|
+
console.log('');
|
|
1168
|
+
console.log(chalk.yellow('Running pipelines:'));
|
|
1169
|
+
for (const p of body.runningPipelines) {
|
|
1170
|
+
console.log(` · ${p.name} (${p.id})`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
console.log('');
|
|
1174
|
+
console.log(chalk.gray('Wait for them to finish or cancel them, then retry.'));
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
}
|
|
1177
|
+
console.error(chalk.red(err.message));
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
projects
|
|
1182
|
+
.command('unarchive')
|
|
1183
|
+
.description('Restore an archived project to active status. Owner/admin only.')
|
|
1184
|
+
.argument('<slug>', 'Project slug (must be archived)')
|
|
1185
|
+
.action(async (slug) => {
|
|
1186
|
+
try {
|
|
1187
|
+
const orgId = requireActiveOrgOrExit();
|
|
1188
|
+
// Archived projects aren't in the default /auth/me response, so we
|
|
1189
|
+
// can't always resolve via cache. Fall back to the full-status list
|
|
1190
|
+
// endpoint to find the id.
|
|
1191
|
+
let project = findProjectBySlug(orgId, slug);
|
|
1192
|
+
if (!project) {
|
|
1193
|
+
const listData = await api('GET', `/api/organizations/${orgId}/projects?status=all`, undefined, { noProjectHeader: true });
|
|
1194
|
+
const match = (listData.projects ?? []).find((p) => p.slug === slug);
|
|
1195
|
+
if (match) {
|
|
1196
|
+
project = {
|
|
1197
|
+
id: match.id,
|
|
1198
|
+
slug: match.slug,
|
|
1199
|
+
name: match.name,
|
|
1200
|
+
colorIndex: match.colorIndex ?? 0,
|
|
1201
|
+
status: match.status,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (!project) {
|
|
1206
|
+
console.error(chalk.red(`Unknown project: ${slug}`));
|
|
1207
|
+
process.exit(1);
|
|
1208
|
+
}
|
|
1209
|
+
const data = await api('POST', `/api/organizations/${orgId}/projects/${project.id}/unarchive`, {}, { noProjectHeader: true });
|
|
1210
|
+
console.log(chalk.green(`Unarchived: ${data.project.name} (${data.project.slug})`));
|
|
1211
|
+
invalidateAuthMeCache();
|
|
1212
|
+
}
|
|
1213
|
+
catch (err) {
|
|
1214
|
+
console.error(chalk.red(err.message));
|
|
1215
|
+
process.exit(1);
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
projects
|
|
1219
|
+
.command('update')
|
|
1220
|
+
.description('Update project name or description. Slug is immutable — to use a different slug, create a new project. Owner/admin only.')
|
|
1221
|
+
.argument('<slug>', 'Project slug')
|
|
1222
|
+
.option('-n, --name <name>', 'New name')
|
|
1223
|
+
.option('-d, --description <desc>', 'New description (pass empty string to clear)')
|
|
1224
|
+
.action(async (slug, opts) => {
|
|
1225
|
+
try {
|
|
1226
|
+
if (!opts.name && opts.description == null) {
|
|
1227
|
+
console.error(chalk.red('Provide --name and/or --description'));
|
|
1228
|
+
process.exit(1);
|
|
1229
|
+
}
|
|
1230
|
+
const orgId = requireActiveOrgOrExit();
|
|
1231
|
+
let project = findProjectBySlug(orgId, slug);
|
|
1232
|
+
if (!project) {
|
|
1233
|
+
await refreshAuthMeCache();
|
|
1234
|
+
project = findProjectBySlug(orgId, slug);
|
|
1235
|
+
}
|
|
1236
|
+
if (!project) {
|
|
1237
|
+
console.error(chalk.red(`Unknown project: ${slug}`));
|
|
1238
|
+
process.exit(1);
|
|
1239
|
+
}
|
|
1240
|
+
const body = {};
|
|
1241
|
+
if (opts.name)
|
|
1242
|
+
body.name = opts.name;
|
|
1243
|
+
if (opts.description != null)
|
|
1244
|
+
body.description = opts.description;
|
|
1245
|
+
const data = await api('PATCH', `/api/organizations/${orgId}/projects/${project.id}`, body, { noProjectHeader: true });
|
|
1246
|
+
console.log(chalk.green(`Updated: ${data.project.name} (${data.project.slug})`));
|
|
1247
|
+
invalidateAuthMeCache();
|
|
1248
|
+
}
|
|
1249
|
+
catch (err) {
|
|
1250
|
+
console.error(chalk.red(err.message));
|
|
1251
|
+
process.exit(1);
|
|
1252
|
+
}
|
|
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
|
+
}
|
|
851
1622
|
// ── Worksheets ──────────────────────────────────────────────
|
|
852
1623
|
const ws = program.command('worksheets').alias('ws').description('Worksheet commands');
|
|
853
1624
|
ws
|
|
854
1625
|
.command('list')
|
|
855
|
-
.description('List worksheets')
|
|
1626
|
+
.description('List worksheets in the active project (or all projects with --all-projects)')
|
|
856
1627
|
.option('-s, --status <status>', 'Filter by status', 'active')
|
|
1628
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
1629
|
+
.option('--all-projects', 'List worksheets across every project in the active org', false)
|
|
857
1630
|
.action(async (opts) => {
|
|
858
1631
|
try {
|
|
859
|
-
const data = await api('GET', `/api/worksheets?status=${opts.status}
|
|
1632
|
+
const data = await api('GET', `/api/worksheets?status=${opts.status}`, undefined, projectOpts(opts));
|
|
860
1633
|
if (data.worksheets.length === 0) {
|
|
861
1634
|
console.log(chalk.yellow('No worksheets'));
|
|
862
1635
|
return;
|
|
@@ -866,16 +1639,16 @@ ws
|
|
|
866
1639
|
}
|
|
867
1640
|
}
|
|
868
1641
|
catch (err) {
|
|
869
|
-
|
|
870
|
-
process.exit(1);
|
|
1642
|
+
handleScopedError(err);
|
|
871
1643
|
}
|
|
872
1644
|
});
|
|
873
1645
|
ws
|
|
874
1646
|
.command('create')
|
|
875
|
-
.description('Create a worksheet')
|
|
1647
|
+
.description('Create a worksheet in the active project')
|
|
876
1648
|
.requiredOption('-n, --name <name>', 'Worksheet name')
|
|
877
1649
|
.option('-p, --purpose <purpose>', 'Purpose (e.g., "From X to Y by When")')
|
|
878
1650
|
.option('--cadence <cadence>', 'Check-in cadence (weekly, biweekly, monthly)', 'weekly')
|
|
1651
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
879
1652
|
.action(async (opts) => {
|
|
880
1653
|
try {
|
|
881
1654
|
const body = { name: opts.name };
|
|
@@ -883,7 +1656,7 @@ ws
|
|
|
883
1656
|
body.purpose = opts.purpose;
|
|
884
1657
|
if (opts.cadence)
|
|
885
1658
|
body.checkInCadence = opts.cadence;
|
|
886
|
-
const data = await api('POST', '/api/worksheets', body);
|
|
1659
|
+
const data = await api('POST', '/api/worksheets', body, projectOpts(opts));
|
|
887
1660
|
const w = data.worksheet;
|
|
888
1661
|
console.log(chalk.green(`Created: ${w.name}`));
|
|
889
1662
|
console.log(` ID: ${w.id}`);
|
|
@@ -891,8 +1664,7 @@ ws
|
|
|
891
1664
|
console.log(` Cadence: ${w.check_in_cadence}`);
|
|
892
1665
|
}
|
|
893
1666
|
catch (err) {
|
|
894
|
-
|
|
895
|
-
process.exit(1);
|
|
1667
|
+
handleScopedError(err);
|
|
896
1668
|
}
|
|
897
1669
|
});
|
|
898
1670
|
ws
|
|
@@ -1960,12 +2732,14 @@ const templates = program.command('templates').alias('tpl').description('Pipelin
|
|
|
1960
2732
|
' Use "tpl show <id>" to see full structure with I/O declarations.');
|
|
1961
2733
|
templates
|
|
1962
2734
|
.command('list')
|
|
1963
|
-
.description('List pipeline templates')
|
|
2735
|
+
.description('List pipeline templates in the active project (or all projects with --all-projects)')
|
|
1964
2736
|
.option('-s, --status <status>', 'Filter by status (draft, active, archived)')
|
|
2737
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
2738
|
+
.option('--all-projects', 'List templates across every project in the active org', false)
|
|
1965
2739
|
.action(async (opts) => {
|
|
1966
2740
|
try {
|
|
1967
2741
|
const qs = opts.status ? `?status=${opts.status}` : '';
|
|
1968
|
-
const data = await api('GET', `/api/pipeline-templates${qs}
|
|
2742
|
+
const data = await api('GET', `/api/pipeline-templates${qs}`, undefined, projectOpts(opts));
|
|
1969
2743
|
if (!data.templates?.length) {
|
|
1970
2744
|
console.log(chalk.yellow('No templates'));
|
|
1971
2745
|
return;
|
|
@@ -1977,8 +2751,7 @@ templates
|
|
|
1977
2751
|
}
|
|
1978
2752
|
}
|
|
1979
2753
|
catch (err) {
|
|
1980
|
-
|
|
1981
|
-
process.exit(1);
|
|
2754
|
+
handleScopedError(err);
|
|
1982
2755
|
}
|
|
1983
2756
|
});
|
|
1984
2757
|
templates
|
|
@@ -3086,10 +3859,12 @@ templates
|
|
|
3086
3859
|
const pipelines = program.command('pipelines').alias('pl').description('Pipeline instance commands');
|
|
3087
3860
|
pipelines
|
|
3088
3861
|
.command('list')
|
|
3089
|
-
.description('List pipeline instances')
|
|
3862
|
+
.description('List pipeline instances in the active project (or all projects with --all-projects)')
|
|
3090
3863
|
.option('-s, --status <status>', 'Filter by status (active, completed, archived)', 'active')
|
|
3091
3864
|
.option('--search <term>', 'Search by name or ID')
|
|
3092
3865
|
.option('--expand', 'Include steps inline (avoids N+1)')
|
|
3866
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
3867
|
+
.option('--all-projects', 'List pipelines across every project in the active org', false)
|
|
3093
3868
|
.action(async (opts) => {
|
|
3094
3869
|
try {
|
|
3095
3870
|
const params = [];
|
|
@@ -3100,7 +3875,7 @@ pipelines
|
|
|
3100
3875
|
if (opts.expand)
|
|
3101
3876
|
params.push('expand=steps');
|
|
3102
3877
|
const qs = params.length ? `?${params.join('&')}` : '';
|
|
3103
|
-
const data = await api('GET', `/api/pipeline-instances${qs}
|
|
3878
|
+
const data = await api('GET', `/api/pipeline-instances${qs}`, undefined, projectOpts(opts));
|
|
3104
3879
|
if (!data.instances?.length) {
|
|
3105
3880
|
console.log(chalk.yellow('No pipeline instances'));
|
|
3106
3881
|
return;
|
|
@@ -3121,8 +3896,7 @@ pipelines
|
|
|
3121
3896
|
}
|
|
3122
3897
|
}
|
|
3123
3898
|
catch (err) {
|
|
3124
|
-
|
|
3125
|
-
process.exit(1);
|
|
3899
|
+
handleScopedError(err);
|
|
3126
3900
|
}
|
|
3127
3901
|
});
|
|
3128
3902
|
pipelines
|
|
@@ -3172,6 +3946,24 @@ pipelines
|
|
|
3172
3946
|
console.log(` ID: ${inst.id}`);
|
|
3173
3947
|
}
|
|
3174
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
|
+
}
|
|
3175
3967
|
console.error(chalk.red(err.message));
|
|
3176
3968
|
process.exit(1);
|
|
3177
3969
|
}
|
|
@@ -3613,10 +4405,12 @@ step
|
|
|
3613
4405
|
const queue = program.command('queue').alias('q').description('Work queue commands');
|
|
3614
4406
|
queue
|
|
3615
4407
|
.command('summary')
|
|
3616
|
-
.description('Show queue depth per agent')
|
|
3617
|
-
.
|
|
4408
|
+
.description('Show queue depth per agent, scoped to the active project (or all projects with --all-projects)')
|
|
4409
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
4410
|
+
.option('--all-projects', 'Show queue across every project in the active org', false)
|
|
4411
|
+
.action(async (opts) => {
|
|
3618
4412
|
try {
|
|
3619
|
-
const data = await api('GET', '/api/work-queue/summary');
|
|
4413
|
+
const data = await api('GET', '/api/work-queue/summary', undefined, projectOpts(opts));
|
|
3620
4414
|
const entries = Object.entries(data.depths ?? {});
|
|
3621
4415
|
if (entries.length === 0) {
|
|
3622
4416
|
console.log(chalk.yellow('No agents in queue'));
|
|
@@ -3631,15 +4425,16 @@ queue
|
|
|
3631
4425
|
console.log(` ${'Total'.padEnd(12)} ${total} steps`);
|
|
3632
4426
|
}
|
|
3633
4427
|
catch (err) {
|
|
3634
|
-
|
|
3635
|
-
process.exit(1);
|
|
4428
|
+
handleScopedError(err);
|
|
3636
4429
|
}
|
|
3637
4430
|
});
|
|
3638
4431
|
queue
|
|
3639
4432
|
.command('list')
|
|
3640
|
-
.description('List queued and in-progress steps')
|
|
4433
|
+
.description('List queued and in-progress steps in the active project (or all projects with --all-projects)')
|
|
3641
4434
|
.option('-a, --agent <name>', 'Filter by agent name')
|
|
3642
4435
|
.option('--message-id <id>', 'Filter by JOE messageId in step metadata')
|
|
4436
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
4437
|
+
.option('--all-projects', 'List queue items across every project in the active org', false)
|
|
3643
4438
|
.action(async (opts) => {
|
|
3644
4439
|
try {
|
|
3645
4440
|
const params = [];
|
|
@@ -3648,7 +4443,7 @@ queue
|
|
|
3648
4443
|
if (opts.messageId)
|
|
3649
4444
|
params.push(`messageId=${encodeURIComponent(opts.messageId)}`);
|
|
3650
4445
|
const qs = params.length ? `?${params.join('&')}` : '';
|
|
3651
|
-
const data = await api('GET', `/api/work-queue${qs}
|
|
4446
|
+
const data = await api('GET', `/api/work-queue${qs}`, undefined, projectOpts(opts));
|
|
3652
4447
|
if (!data.items?.length) {
|
|
3653
4448
|
console.log(chalk.yellow('No queued steps'));
|
|
3654
4449
|
return;
|
|
@@ -3660,8 +4455,7 @@ queue
|
|
|
3660
4455
|
}
|
|
3661
4456
|
}
|
|
3662
4457
|
catch (err) {
|
|
3663
|
-
|
|
3664
|
-
process.exit(1);
|
|
4458
|
+
handleScopedError(err);
|
|
3665
4459
|
}
|
|
3666
4460
|
});
|
|
3667
4461
|
// ── Executions ─────────────────────────────────────────────
|
|
@@ -3773,6 +4567,22 @@ program
|
|
|
3773
4567
|
console.log(` Status: ${exec.status}`);
|
|
3774
4568
|
}
|
|
3775
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
|
+
}
|
|
3776
4586
|
console.error(chalk.red(err.message));
|
|
3777
4587
|
process.exit(1);
|
|
3778
4588
|
}
|
|
@@ -4155,10 +4965,12 @@ executions
|
|
|
4155
4965
|
// ── My Work ─────────────────────────────────────────────────
|
|
4156
4966
|
program
|
|
4157
4967
|
.command('my-work')
|
|
4158
|
-
.description('Show pending work
|
|
4159
|
-
.
|
|
4968
|
+
.description('Show pending work in the active project (or all projects with --all-projects)')
|
|
4969
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
4970
|
+
.option('--all-projects', 'Show pending work across every project in the active org', false)
|
|
4971
|
+
.action(async (opts) => {
|
|
4160
4972
|
try {
|
|
4161
|
-
const data = await api('GET', '/api/my-work');
|
|
4973
|
+
const data = await api('GET', '/api/my-work', undefined, projectOpts(opts));
|
|
4162
4974
|
const c = data.commitments;
|
|
4163
4975
|
const total = c.overdue.length + c.due_today.length + c.due_this_week.length + c.upcoming.length;
|
|
4164
4976
|
console.log(chalk.cyan(`Commitments: ${total}`));
|
|
@@ -4175,8 +4987,7 @@ program
|
|
|
4175
4987
|
console.log(chalk.cyan(`Pipeline steps: ${data.pipeline_steps.length}`));
|
|
4176
4988
|
}
|
|
4177
4989
|
catch (err) {
|
|
4178
|
-
|
|
4179
|
-
process.exit(1);
|
|
4990
|
+
handleScopedError(err);
|
|
4180
4991
|
}
|
|
4181
4992
|
});
|
|
4182
4993
|
// ── Test ────────────────────────────────────────────────────
|