@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/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 } from './config.js';
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 org context')
255
+ .description('Show current user, org, and project context')
253
256
  .action(async () => {
254
257
  try {
255
- const data = await api('GET', '/auth/me');
256
- console.log(` User: ${chalk.cyan(data.user.email)}`);
257
- console.log(` Member: ${data.member.displayName} (${data.member.role})`);
258
- console.log(` Org: ${data.member.orgId}`);
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
- console.error(chalk.red(err.message));
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
- console.error(chalk.red(err.message));
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
- console.error(chalk.red(err.message));
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
- console.error(chalk.red(err.message));
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
- .action(async () => {
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
- console.error(chalk.red(err.message));
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
- console.error(chalk.red(err.message));
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 across all worksheets')
4159
- .action(async () => {
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
- console.error(chalk.red(err.message));
4179
- process.exit(1);
4990
+ handleScopedError(err);
4180
4991
  }
4181
4992
  });
4182
4993
  // ── Test ────────────────────────────────────────────────────