@formigio/fazemos-cli 0.4.8 → 0.5.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 CHANGED
@@ -1 +1,14 @@
1
+ /**
2
+ * Error thrown when an API request returns a non-2xx response. Carries the
3
+ * `code` field from the API's error envelope (when present) so command
4
+ * handlers can branch on specific failure modes (e.g., EMAIL_SEND_FAILED).
5
+ * Also exposes the parsed body so callers can read extra fields like
6
+ * `inviteEmail`/`sessionEmail` on INVITE_EMAIL_MISMATCH.
7
+ */
8
+ export declare class ApiError extends Error {
9
+ status: number;
10
+ code?: string;
11
+ body?: unknown;
12
+ constructor(message: string, status: number, code?: string, body?: unknown);
13
+ }
1
14
  export declare function api(method: string, path: string, body?: unknown): Promise<unknown>;
package/dist/api.js CHANGED
@@ -1,5 +1,24 @@
1
1
  import { getEnv, getToken, getActiveOrgId } from './config.js';
2
2
  import { refreshSession } from './auth.js';
3
+ /**
4
+ * Error thrown when an API request returns a non-2xx response. Carries the
5
+ * `code` field from the API's error envelope (when present) so command
6
+ * handlers can branch on specific failure modes (e.g., EMAIL_SEND_FAILED).
7
+ * Also exposes the parsed body so callers can read extra fields like
8
+ * `inviteEmail`/`sessionEmail` on INVITE_EMAIL_MISMATCH.
9
+ */
10
+ export class ApiError extends Error {
11
+ status;
12
+ code;
13
+ body;
14
+ constructor(message, status, code, body) {
15
+ super(message);
16
+ this.name = 'ApiError';
17
+ this.status = status;
18
+ this.code = code;
19
+ this.body = body;
20
+ }
21
+ }
3
22
  export async function api(method, path, body) {
4
23
  const env = getEnv();
5
24
  let token = getToken();
@@ -38,10 +57,11 @@ export async function api(method, path, body) {
38
57
  data = text;
39
58
  }
40
59
  if (!response.ok) {
41
- const msg = typeof data === 'object' && data !== null && 'error' in data
42
- ? data.error
43
- : `HTTP ${response.status}`;
44
- throw new Error(msg);
60
+ const obj = typeof data === 'object' && data !== null
61
+ ? data
62
+ : null;
63
+ const msg = obj?.error ?? `HTTP ${response.status}`;
64
+ throw new ApiError(msg, response.status, obj?.code, data);
45
65
  }
46
66
  return data;
47
67
  }
package/dist/api.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAE3C,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,MAAc,EAAE,IAAY,EAAE,IAAc;IACpE,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC;IACrB,IAAI,KAAK,GAAG,QAAQ,EAAE,CAAC;IACvB,MAAM,KAAK,GAAG,cAAc,EAAE,CAAC;IAE/B,mCAAmC;IACnC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,KAAK,GAAG,MAAM,cAAc,EAAE,CAAC;QAC/B,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;QAC9D,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAA2B;QACtC,cAAc,EAAE,kBAAkB;KACnC,CAAC;IACF,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,CAAC,eAAe,CAAC,GAAG,UAAU,KAAK,EAAE,CAAC;IAC/C,CAAC;IACD,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,CAAC,UAAU,CAAC,GAAG,KAAK,CAAC;IAC9B,CAAC;IAED,MAAM,GAAG,GAAG,GAAG,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE,CAAC;IACnC,MAAM,OAAO,GAAgB;QAC3B,MAAM;QACN,OAAO;KACR,CAAC;IAEF,IAAI,IAAI,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;QAC7B,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC3C,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IAEnC,IAAI,IAAa,CAAC;IAClB,IAAI,CAAC;QACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,IAAI,GAAG,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,GAAG,GAAG,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,IAAI;YACtE,CAAC,CAAE,IAA0B,CAAC,KAAK;YACnC,CAAC,CAAC,QAAQ,QAAQ,CAAC,MAAM,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,GAAG,CAAC,CAAC;IACvB,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC"}
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAE3C;;;;;;GAMG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IACjC,MAAM,CAAS;IACf,IAAI,CAAU;IACd,IAAI,CAAW;IACf,YAAY,OAAe,EAAE,MAAc,EAAE,IAAa,EAAE,IAAc;QACxE,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,MAAM,CAAC,KAAK,UAAU,GAAG,CAAC,MAAc,EAAE,IAAY,EAAE,IAAc;IACpE,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC;IACrB,IAAI,KAAK,GAAG,QAAQ,EAAE,CAAC;IACvB,MAAM,KAAK,GAAG,cAAc,EAAE,CAAC;IAE/B,mCAAmC;IACnC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,KAAK,GAAG,MAAM,cAAc,EAAE,CAAC;QAC/B,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;QAC9D,CAAC;IACH,CAAC;IAED,MAAM,OAAO,GAA2B;QACtC,cAAc,EAAE,kBAAkB;KACnC,CAAC;IACF,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,CAAC,eAAe,CAAC,GAAG,UAAU,KAAK,EAAE,CAAC;IAC/C,CAAC;IACD,IAAI,KAAK,EAAE,CAAC;QACV,OAAO,CAAC,UAAU,CAAC,GAAG,KAAK,CAAC;IAC9B,CAAC;IAED,MAAM,GAAG,GAAG,GAAG,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE,CAAC;IACnC,MAAM,OAAO,GAAgB;QAC3B,MAAM;QACN,OAAO;KACR,CAAC;IAEF,IAAI,IAAI,IAAI,MAAM,KAAK,KAAK,EAAE,CAAC;QAC7B,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IAC3C,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IAEnC,IAAI,IAAa,CAAC;IAClB,IAAI,CAAC;QACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAAC,MAAM,CAAC;QACP,IAAI,GAAG,IAAI,CAAC;IACd,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,GAAG,GACP,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI;YACvC,CAAC,CAAE,IAA0C;YAC7C,CAAC,CAAC,IAAI,CAAC;QACX,MAAM,GAAG,GAAG,GAAG,EAAE,KAAK,IAAI,QAAQ,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpD,MAAM,IAAI,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;IAC5D,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC"}
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironment, hasEnvironments } from './config.js';
5
5
  import { login, signup, confirmSignup, adminLogin } from './auth.js';
6
- import { api } from './api.js';
6
+ import { api, ApiError } from './api.js';
7
7
  import { execSync } from 'child_process';
8
8
  import { readFileSync, readdirSync } from 'fs';
9
9
  import { fileURLToPath } from 'url';
@@ -330,9 +330,10 @@ orgs
330
330
  });
331
331
  orgs
332
332
  .command('invite')
333
- .description('Invite a human member to the organization by email. The invitee receives an email to join. Use "orgs add-agent" for AI agents instead.')
333
+ .description('Invite a human member to the organization by email. The invitee receives an email with a one-time link to join. Use "orgs add-agent" for AI agents instead.')
334
334
  .requiredOption('-e, --email <email>', 'Email address to invite')
335
335
  .option('-r, --role <role>', 'Organization role: admin (full access) or member (default)', 'member')
336
+ .option('-d, --display-name <name>', 'Optional display name for the invitee')
336
337
  .action(async (opts) => {
337
338
  try {
338
339
  const orgId = getActiveOrgId();
@@ -343,14 +344,81 @@ orgs
343
344
  const data = await api('POST', `/api/organizations/${orgId}/members/invite`, {
344
345
  email: opts.email,
345
346
  role: opts.role,
347
+ displayName: opts.displayName,
348
+ });
349
+ printInviteSuccess({
350
+ email: opts.email,
351
+ role: opts.role,
352
+ member: data.member,
353
+ inviteLink: data.inviteLink,
354
+ expiresAt: data.expiresAt,
346
355
  });
347
- console.log(chalk.green(`Invited ${opts.email} as ${opts.role}`));
348
356
  }
349
357
  catch (err) {
350
- console.error(chalk.red(err.message));
358
+ // EMAIL_SEND_FAILED carries the member row + invite link in the body
359
+ // because the row was committed in 'failed' state for retry. Show
360
+ // the warning UX (yellow) instead of a hard error.
361
+ if (err instanceof ApiError && err.code === 'EMAIL_SEND_FAILED') {
362
+ const body = (err.body ?? {});
363
+ printInviteSendFailedWarning({
364
+ email: opts.email,
365
+ role: opts.role,
366
+ member: body.member,
367
+ inviteLink: body.inviteLink,
368
+ sendError: body.error || err.message,
369
+ });
370
+ process.exit(1);
371
+ }
372
+ const msg = err instanceof Error ? err.message : String(err);
373
+ console.error(chalk.red(`Error: ${msg}`));
351
374
  process.exit(1);
352
375
  }
353
376
  });
377
+ function printInviteSuccess(args) {
378
+ const expiry = new Date(args.expiresAt);
379
+ const absoluteFmt = new Intl.DateTimeFormat('en-US', {
380
+ weekday: 'short',
381
+ month: 'short',
382
+ day: 'numeric',
383
+ year: 'numeric',
384
+ });
385
+ const days = Math.max(1, Math.round((expiry.getTime() - Date.now()) / (24 * 60 * 60 * 1000)));
386
+ console.log(chalk.bold(chalk.green(`Invited ${args.email} as ${args.role}.`)));
387
+ console.log('');
388
+ console.log(` Email sent to: ${args.email}`);
389
+ console.log(` Role: ${args.role}`);
390
+ if (args.member?.id) {
391
+ console.log(` Member ID: ${args.member.id}`);
392
+ }
393
+ console.log(` Expires: ${absoluteFmt.format(expiry)} (${days} days)`);
394
+ console.log('');
395
+ console.log(chalk.gray(' Invite link (for manual sharing):'));
396
+ console.log(chalk.gray(` ${args.inviteLink}`));
397
+ }
398
+ function printInviteSendFailedWarning(args) {
399
+ // HP5: warnings go to stderr so scripts piping stdout (e.g., for parsing
400
+ // the invite link or member id) don't get the recoverable warning text
401
+ // mixed into their parsed output. Exit code is already 1.
402
+ console.error(chalk.yellow('Warning: invite created but email delivery failed.'));
403
+ console.error('');
404
+ console.error(` Email: ${args.email}`);
405
+ console.error(` Role: ${args.role}`);
406
+ // HP4: Member ID is the row the operator needs to act on. Show it in the
407
+ // same labeled position as the success block, immediately after Role.
408
+ if (args.member?.id) {
409
+ console.error(` Member ID: ${args.member.id}`);
410
+ }
411
+ console.error(` Invite status: ${chalk.red('failed')} (${args.sendError})`);
412
+ if (args.inviteLink) {
413
+ console.error('');
414
+ console.error(chalk.gray(' Invite link (share manually or use `fazemos orgs invites resend`):'));
415
+ console.error(chalk.gray(` ${args.inviteLink}`));
416
+ }
417
+ if (args.member?.id) {
418
+ console.error('');
419
+ console.error(` To retry: fazemos orgs invites resend ${args.member.id}`);
420
+ }
421
+ }
354
422
  orgs
355
423
  .command('add-agent')
356
424
  .description('Create an AI agent member in the organization. The agent can then be assigned work via actions, commitments, or pipeline steps. Use "agents register" for bulk registration with roles and config.')
@@ -375,27 +443,44 @@ orgs
375
443
  process.exit(1);
376
444
  }
377
445
  });
446
+ const ORGS_MEMBERS_STATUSES = ['active', 'invited', 'failed', 'all'];
378
447
  orgs
379
448
  .command('members')
380
449
  .description('List org members')
381
450
  .option('--agents', 'Show only agent members')
451
+ .option('--status <status>', 'Filter by status: active|invited|failed|all (default: active)', 'active')
382
452
  .action(async (opts) => {
383
453
  try {
454
+ const status = opts.status || 'active';
455
+ if (!ORGS_MEMBERS_STATUSES.includes(status)) {
456
+ console.error(chalk.red(`Invalid --status "${status}". Allowed values: ${ORGS_MEMBERS_STATUSES.join(', ')}`));
457
+ process.exit(1);
458
+ }
384
459
  const orgId = getActiveOrgId();
385
460
  if (!orgId) {
386
461
  console.error(chalk.red('No active org. Run: fazemos orgs switch <slug>'));
387
462
  process.exit(1);
388
463
  }
389
- const data = await api('GET', `/api/organizations/${orgId}/members`);
464
+ const data = await api('GET', `/api/organizations/${orgId}/members?status=${encodeURIComponent(status)}`);
390
465
  if (!data.members?.length) {
391
- console.log(chalk.yellow('No members'));
466
+ console.log(chalk.yellow(`No members (status=${status})`));
392
467
  return;
393
468
  }
469
+ if (status === 'invited' || status === 'failed') {
470
+ printPendingInvites(data.members);
471
+ return;
472
+ }
473
+ // S6: when status !== 'active', the result set may contain a mix of
474
+ // statuses (active, invited, failed, revoked). Show the row's status
475
+ // inline so callers can tell them apart. The default `--status active`
476
+ // path stays unchanged (no status noise).
477
+ const showStatus = status !== 'active';
394
478
  for (const m of data.members) {
395
479
  if (opts.agents && m.member_type !== 'agent')
396
480
  continue;
397
481
  const type = m.member_type === 'agent' ? chalk.blue('agent') : 'human';
398
- console.log(` ${chalk.cyan(m.display_name)} (${m.role}, ${type}) — ${m.id}`);
482
+ const statusBadge = showStatus ? ` ${formatMemberStatusBadge(m.status)}` : '';
483
+ console.log(` ${chalk.cyan(m.display_name)} (${m.role}, ${type})${statusBadge} — ${m.id}`);
399
484
  }
400
485
  }
401
486
  catch (err) {
@@ -403,6 +488,171 @@ orgs
403
488
  process.exit(1);
404
489
  }
405
490
  });
491
+ function printPendingInvites(members) {
492
+ console.log(chalk.bold(`Pending invites (${members.length}):`));
493
+ console.log('');
494
+ console.log(' EMAIL ROLE INVITED BY SENT EXPIRES STATUS ID');
495
+ for (const m of members) {
496
+ const email = (m.email || '').padEnd(30).slice(0, 30);
497
+ const role = (m.role || '').padEnd(8).slice(0, 8);
498
+ const inviter = (m.inviter_name || 'unknown').padEnd(17).slice(0, 17);
499
+ const sent = m.invite_last_sent_at
500
+ ? formatRelative(new Date(m.invite_last_sent_at)).padEnd(16).slice(0, 16)
501
+ : 'never ';
502
+ let expires;
503
+ if (m.invite_expires_at) {
504
+ const e = formatExpiresAt(new Date(m.invite_expires_at));
505
+ // Pad the visible text first, then colorize. Colorizing before padding
506
+ // would break .padEnd because chalk's ANSI escape codes inflate .length.
507
+ expires = e.expired
508
+ ? chalk.red(e.text.padEnd(15))
509
+ : e.text.padEnd(15);
510
+ }
511
+ else {
512
+ expires = 'unknown ';
513
+ }
514
+ const status = m.status === 'failed' ? chalk.red('failed ') : chalk.green('invited');
515
+ const resendNote = m.invite_resend_count
516
+ ? chalk.gray(` (resent ${m.invite_resend_count}x)`)
517
+ : '';
518
+ console.log(` ${email} ${role} ${inviter} ${sent} ${expires} ${status} ${m.id}${resendNote}`);
519
+ }
520
+ console.log('');
521
+ console.log(chalk.gray('Use `fazemos orgs invites resend <member-id>` to resend.'));
522
+ console.log(chalk.gray('Use `fazemos orgs invites revoke <member-id>` to revoke.'));
523
+ }
524
+ function formatRelative(d) {
525
+ const diffMs = Date.now() - d.getTime();
526
+ const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
527
+ if (days <= 0)
528
+ return 'today';
529
+ if (days === 1)
530
+ return '1 day ago';
531
+ return `${days} days ago`;
532
+ }
533
+ function formatExpiresAt(d) {
534
+ const diffMs = d.getTime() - Date.now();
535
+ if (diffMs <= 0)
536
+ return { text: 'EXPIRED', expired: true };
537
+ const days = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
538
+ if (days === 1)
539
+ return { text: 'in 1 day', expired: false };
540
+ return { text: `in ${days} days`, expired: false };
541
+ }
542
+ // S6: render a per-row status badge for the bullet member list. Used only
543
+ // when --status is not the default 'active' so a mixed result set (e.g. from
544
+ // --status all) is unambiguous about which row is active vs revoked vs etc.
545
+ function formatMemberStatusBadge(status) {
546
+ const s = (status || 'unknown').toLowerCase();
547
+ switch (s) {
548
+ case 'active': return chalk.green('[active]');
549
+ case 'invited': return chalk.yellow('[invited]');
550
+ case 'failed': return chalk.red('[failed]');
551
+ case 'revoked': return chalk.gray('[revoked]');
552
+ default: return chalk.gray(`[${s}]`);
553
+ }
554
+ }
555
+ // ── orgs invites subcommands ────────────────────────────────
556
+ const invites = orgs.command('invites').description('Manage pending org invites');
557
+ const INVITES_LIST_STATUSES = ['invited', 'failed', 'all'];
558
+ invites
559
+ .command('list')
560
+ .description('List pending invites for the active org (alias of `orgs members --status invited`)')
561
+ .option('--status <status>', 'Filter: invited|failed|all (default: invited)', 'invited')
562
+ .action(async (opts) => {
563
+ try {
564
+ const status = opts.status || 'invited';
565
+ if (!INVITES_LIST_STATUSES.includes(status)) {
566
+ console.error(chalk.red(`Invalid --status "${status}". Allowed values: ${INVITES_LIST_STATUSES.join(', ')}`));
567
+ process.exit(1);
568
+ }
569
+ const orgId = getActiveOrgId();
570
+ if (!orgId) {
571
+ console.error(chalk.red('No active org. Run: fazemos orgs switch <slug>'));
572
+ process.exit(1);
573
+ }
574
+ // S6: `orgs invites list` is scoped to invites. When --status all is
575
+ // passed it should mean "all invite statuses" (invited + failed), NOT
576
+ // "all members regardless of invite status". The backend ?status=all
577
+ // returns every member, so we filter client-side here. We keep the
578
+ // single ?status=all call (rather than two calls for invited+failed)
579
+ // for simplicity — do not "optimize" this into the underlying status
580
+ // params without re-checking that the API supports a multi-value filter.
581
+ const data = await api('GET', `/api/organizations/${orgId}/members?status=${encodeURIComponent(status)}`);
582
+ let rows = data.members || [];
583
+ if (status === 'all') {
584
+ rows = rows.filter((m) => m.status === 'invited' || m.status === 'failed');
585
+ }
586
+ if (!rows.length) {
587
+ const label = status === 'all' ? 'pending or failed' : status;
588
+ console.log(chalk.yellow(`No ${label} invites.`));
589
+ return;
590
+ }
591
+ printPendingInvites(rows);
592
+ }
593
+ catch (err) {
594
+ console.error(chalk.red(err.message));
595
+ process.exit(1);
596
+ }
597
+ });
598
+ invites
599
+ .command('resend <memberId>')
600
+ .description('Generate a fresh invite token and re-send the email')
601
+ .action(async (memberId) => {
602
+ try {
603
+ const orgId = getActiveOrgId();
604
+ if (!orgId) {
605
+ console.error(chalk.red('No active org. Run: fazemos orgs switch <slug>'));
606
+ process.exit(1);
607
+ }
608
+ const data = await api('POST', `/api/organizations/${orgId}/members/${memberId}/resend`, {});
609
+ console.log(chalk.green(`Invite resent to ${data.member.email}.`));
610
+ console.log('');
611
+ const expiry = new Date(data.expiresAt);
612
+ const days = Math.max(1, Math.round((expiry.getTime() - Date.now()) / (24 * 60 * 60 * 1000)));
613
+ const fmt = new Intl.DateTimeFormat('en-US', {
614
+ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric',
615
+ });
616
+ console.log(` New expiry: ${fmt.format(expiry)} (${days} days)`);
617
+ console.log(chalk.gray(` Invite link: ${data.inviteLink}`));
618
+ }
619
+ catch (err) {
620
+ if (err instanceof ApiError && err.code === 'EMAIL_SEND_FAILED') {
621
+ const body = (err.body ?? {});
622
+ // HP5: warnings go to stderr so scripts piping stdout don't get
623
+ // recoverable warnings mixed into their parsed output.
624
+ console.error(chalk.yellow('Warning: invite resent but email delivery failed.'));
625
+ console.error('');
626
+ console.error(` Member ID: ${memberId}`);
627
+ console.error(` Reason: ${body.error || err.message}`);
628
+ if (body.inviteLink) {
629
+ console.error(chalk.gray(` Invite link: ${body.inviteLink}`));
630
+ }
631
+ process.exit(1);
632
+ }
633
+ const msg = err instanceof Error ? err.message : String(err);
634
+ console.error(chalk.red(`Error: ${msg}`));
635
+ process.exit(1);
636
+ }
637
+ });
638
+ invites
639
+ .command('revoke <memberId>')
640
+ .description('Revoke a pending invite immediately (invalidates the token)')
641
+ .action(async (memberId) => {
642
+ try {
643
+ const orgId = getActiveOrgId();
644
+ if (!orgId) {
645
+ console.error(chalk.red('No active org. Run: fazemos orgs switch <slug>'));
646
+ process.exit(1);
647
+ }
648
+ await api('POST', `/api/organizations/${orgId}/members/${memberId}/revoke`, {});
649
+ console.log(chalk.green(`Invite for member ${memberId} has been revoked.`));
650
+ }
651
+ catch (err) {
652
+ console.error(chalk.red(`Error: ${err.message}`));
653
+ process.exit(1);
654
+ }
655
+ });
406
656
  // ── Worksheets ──────────────────────────────────────────────
407
657
  const ws = program.command('worksheets').alias('ws').description('Worksheet commands');
408
658
  ws
@@ -4136,5 +4386,104 @@ program
4136
4386
  process.exit(1);
4137
4387
  }
4138
4388
  });
4389
+ // ── Ops ────────────────────────────────────────────────────
4390
+ const ops = program.command('ops').description('Operational commands for sweep, failures, and system health');
4391
+ ops
4392
+ .command('sweep')
4393
+ .description('Trigger dead execution + stuck step sweep. Detects containers that died without reporting back and steps orphaned with no active execution.')
4394
+ .action(async () => {
4395
+ try {
4396
+ console.log(chalk.cyan('Running sweep...'));
4397
+ const data = await api('POST', '/api/ops/sweep');
4398
+ console.log(chalk.green('Sweep complete'));
4399
+ console.log(` Dead executions: ${data.dead_executions_found} found, ${data.dead_executions_reaped} reaped`);
4400
+ console.log(` Stuck steps: ${data.stuck_steps_found} found, ${data.stuck_steps_recovered} recovered, ${data.stuck_steps_flagged} flagged`);
4401
+ console.log(` Duration: ${data.duration_ms}ms`);
4402
+ if (data.errors?.length) {
4403
+ console.log(chalk.yellow(` Errors: ${data.errors.length}`));
4404
+ for (const err of data.errors) {
4405
+ console.log(chalk.yellow(` - ${err}`));
4406
+ }
4407
+ }
4408
+ }
4409
+ catch (err) {
4410
+ console.error(chalk.red(err.message));
4411
+ process.exit(1);
4412
+ }
4413
+ });
4414
+ ops
4415
+ .command('failures')
4416
+ .description('Show failure summary by category and agent')
4417
+ .option('-p, --period <period>', 'Time period: 24h, 7d, 30d', '7d')
4418
+ .option('-a, --agent <name>', 'Filter by agent name')
4419
+ .action(async (opts) => {
4420
+ try {
4421
+ const params = [];
4422
+ if (opts.period)
4423
+ params.push(`period=${opts.period}`);
4424
+ if (opts.agent)
4425
+ params.push(`agent=${encodeURIComponent(opts.agent)}`);
4426
+ const qs = params.length ? `?${params.join('&')}` : '';
4427
+ const data = await api('GET', `/api/ops/failure-summary${qs}`);
4428
+ const s = data.summary;
4429
+ if (s.total_failed === 0) {
4430
+ console.log(chalk.green(`No failures in the last ${s.period}`));
4431
+ return;
4432
+ }
4433
+ console.log(chalk.cyan(`Failure summary (last ${s.period}):`));
4434
+ console.log(` Total: ${s.total_failed} failed executions\n`);
4435
+ console.log(' By category:');
4436
+ const cats = Object.entries(s.by_category).sort((a, b) => b[1] - a[1]);
4437
+ for (const [cat, count] of cats) {
4438
+ const pct = Math.round((count / s.total_failed) * 100);
4439
+ console.log(` ${String(cat).padEnd(22)} ${String(count).padStart(3)} (${pct}%)`);
4440
+ }
4441
+ console.log('\n By agent:');
4442
+ const agents = Object.entries(s.by_agent).sort((a, b) => b[1].total - a[1].total);
4443
+ for (const [agent, cats] of agents) {
4444
+ const agentCats = cats;
4445
+ const total = agentCats.total || 0;
4446
+ const details = Object.entries(agentCats)
4447
+ .filter(([k]) => k !== 'total')
4448
+ .sort((a, b) => b[1] - a[1])
4449
+ .map(([k, v]) => `${k}: ${v}`)
4450
+ .join(', ');
4451
+ console.log(` ${agent.padEnd(16)} ${String(total).padStart(3)} failures (${details})`);
4452
+ }
4453
+ }
4454
+ catch (err) {
4455
+ console.error(chalk.red(err.message));
4456
+ process.exit(1);
4457
+ }
4458
+ });
4459
+ ops
4460
+ .command('sweep-log')
4461
+ .description('Show recent sweep results')
4462
+ .option('-l, --limit <n>', 'Number of entries', '10')
4463
+ .action(async (opts) => {
4464
+ try {
4465
+ const data = await api('GET', `/api/ops/sweep-log?limit=${opts.limit}`);
4466
+ if (!data.sweeps?.length) {
4467
+ console.log(chalk.yellow('No sweep activity recorded'));
4468
+ return;
4469
+ }
4470
+ for (const s of data.sweeps) {
4471
+ const time = s.timestamp ? new Date(s.timestamp).toLocaleString() : '';
4472
+ const parts = [];
4473
+ if (s.dead_executions_reaped)
4474
+ parts.push(`${s.dead_executions_reaped} reaped`);
4475
+ if (s.stuck_steps_recovered)
4476
+ parts.push(`${s.stuck_steps_recovered} recovered`);
4477
+ if (s.stuck_steps_flagged)
4478
+ parts.push(`${s.stuck_steps_flagged} flagged`);
4479
+ const summary = parts.length ? parts.join(', ') : 'no action';
4480
+ console.log(` ${chalk.gray(time)} ${summary} (${s.duration_ms}ms)`);
4481
+ }
4482
+ }
4483
+ catch (err) {
4484
+ console.error(chalk.red(err.message));
4485
+ process.exit(1);
4486
+ }
4487
+ });
4139
4488
  program.parse();
4140
4489
  //# sourceMappingURL=index.js.map