@formigio/fazemos-cli 0.10.15 → 0.10.17

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
@@ -10,6 +10,7 @@ import { isProjectConnectionUnavailable, renderProjectConnectionUnavailableCopy,
10
10
  import { loadYaml, summarize } from './yaml/load.js';
11
11
  import { printFindings, printJson } from './yaml/format.js';
12
12
  import { validateManifest } from './manifest/checks.js';
13
+ import { findLocalRegistry, resolveRole, buildInboxFile, writeInboxFile, buildNotificationPayload, gitCommitInboxFile, } from './dispatch.js';
13
14
  import { execSync } from 'child_process';
14
15
  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
15
16
  import { fileURLToPath } from 'url';
@@ -5538,14 +5539,57 @@ executions
5538
5539
  // ── My Work ─────────────────────────────────────────────────
5539
5540
  program
5540
5541
  .command('my-work')
5541
- .description('Show pending work in the active project (or all projects with --all-projects)')
5542
+ .description('Show pending work in the active project (or all projects with --all-projects, or across every org with --all-orgs)')
5542
5543
  .option('--project <slug>', 'Override active project for this call')
5543
5544
  .option('--all-projects', 'Show pending work across every project in the active org', false)
5545
+ .option('-a, --all-orgs', 'Show pending work across every org you are a member of (calls /api/my-work/unified)', false)
5546
+ .option('--json', 'Emit machine-readable JSON', false)
5544
5547
  .action(async (opts) => {
5548
+ // ── F19 — cross-Org unified path ───────────────────────────
5549
+ // When --all-orgs is set, we drop X-Org-Id / X-Fazemos-Project-Id
5550
+ // headers and call the unified endpoint. The endpoint is mounted
5551
+ // BEFORE requireOrgMembership and runs across every Org the caller is
5552
+ // an active member of. See rul_cross_org_auth_no_org_middleware.
5553
+ if (opts.allOrgs) {
5554
+ try {
5555
+ const data = await api('GET', '/api/my-work/unified', undefined, { noProjectHeader: true });
5556
+ if (opts.json) {
5557
+ console.log(JSON.stringify(data, null, 2));
5558
+ return;
5559
+ }
5560
+ // Sage edge case #24: single-Org user with --all-orgs gets a soft
5561
+ // notice + their normal output (no flag-stripping, no error).
5562
+ if (data.meta.orgCount === 1) {
5563
+ console.log(chalk.yellow("--all-orgs has no effect — you're a member of 1 org. Showing your current scope.\n"));
5564
+ }
5565
+ renderUnifiedMyWork(data);
5566
+ return;
5567
+ }
5568
+ catch (err) {
5569
+ // Defensive: 403 NO_ACTIVE_MEMBERSHIP gets a friendly nudge.
5570
+ if (err instanceof ApiError && err.code === 'NO_ACTIVE_MEMBERSHIP') {
5571
+ console.error(chalk.red('No active org memberships.'));
5572
+ console.error(chalk.gray('Ask an admin to add you, or accept a pending invite.'));
5573
+ process.exit(1);
5574
+ }
5575
+ if (err instanceof Error) {
5576
+ console.error(chalk.red(err.message));
5577
+ }
5578
+ else {
5579
+ console.error(chalk.red(String(err)));
5580
+ }
5581
+ process.exit(1);
5582
+ }
5583
+ }
5584
+ // ── Default project-scoped path (unchanged) ─────────────────
5545
5585
  try {
5546
5586
  const data = await api('GET', '/api/my-work', undefined, projectOpts(opts));
5547
5587
  const c = data.commitments;
5548
5588
  const total = c.overdue.length + c.due_today.length + c.due_this_week.length + c.upcoming.length;
5589
+ if (opts.json) {
5590
+ console.log(JSON.stringify(data, null, 2));
5591
+ return;
5592
+ }
5549
5593
  console.log(chalk.cyan(`Commitments: ${total}`));
5550
5594
  if (c.overdue.length)
5551
5595
  console.log(chalk.red(` Overdue: ${c.overdue.length}`));
@@ -5563,6 +5607,81 @@ program
5563
5607
  handleScopedError(err);
5564
5608
  }
5565
5609
  });
5610
+ function renderUnifiedMyWork(data) {
5611
+ if (data.items.length === 0) {
5612
+ console.log(chalk.green("Looking good — across every project, in every org, you're caught up."));
5613
+ return;
5614
+ }
5615
+ const today = new Date().toISOString().slice(0, 10);
5616
+ const truncate = (s, n) => s.length > n ? s.slice(0, n - 1) + '…' : s;
5617
+ // Header
5618
+ const header = [
5619
+ pad('STATUS', 10),
5620
+ pad('TYPE', 9),
5621
+ pad('TITLE', 36),
5622
+ pad('ORG', 5),
5623
+ pad('PROJ', 5),
5624
+ 'DUE',
5625
+ ].join(' ');
5626
+ console.log(chalk.gray(header));
5627
+ console.log(chalk.gray('-'.repeat(80)));
5628
+ for (const item of data.items) {
5629
+ // Status color rules per manifest.cli.changes.
5630
+ let statusLabel;
5631
+ if (item.dueDate && item.dueDate < today) {
5632
+ statusLabel = chalk.red('overdue');
5633
+ }
5634
+ else if (item.dueDate === today) {
5635
+ statusLabel = chalk.yellow('today');
5636
+ }
5637
+ else {
5638
+ statusLabel = item.status;
5639
+ }
5640
+ const row = [
5641
+ pad(statusLabel, 10),
5642
+ pad(item.type, 9),
5643
+ pad(truncate(item.title, 36), 36),
5644
+ pad(`[${computeAbbreviation(item.org.name)}]`, 5),
5645
+ pad(`[${computeAbbreviation(item.project.name)}]`, 5),
5646
+ item.dueDate ?? '—',
5647
+ ].join(' ');
5648
+ console.log(row);
5649
+ }
5650
+ // Footer
5651
+ const shown = data.items.length;
5652
+ const total = data.meta.typeCounts.commitment +
5653
+ data.meta.typeCounts.action +
5654
+ data.meta.typeCounts.worksheet +
5655
+ data.meta.typeCounts.check_in +
5656
+ data.meta.typeCounts.pipeline_step;
5657
+ console.log('');
5658
+ console.log(chalk.gray(`Showing ${shown} of ${total}.` +
5659
+ (data.nextCursor ? ' More available — re-run with --cursor=<next>.' : '')));
5660
+ console.log(chalk.gray(`Tip: fazemos my-work (no flag) shows just the active project.`));
5661
+ }
5662
+ /**
5663
+ * 2-letter abbreviation: first letter of first word + first letter of next
5664
+ * word, or first 2 letters of the only word if no second word exists.
5665
+ * Duplicates the helper used in fazemos-web (ProjectBadge.tsx) per
5666
+ * manifest.cli.shared_helpers — "don't block on packaging."
5667
+ */
5668
+ function computeAbbreviation(name) {
5669
+ const cleaned = name.trim();
5670
+ if (!cleaned)
5671
+ return '??';
5672
+ const words = cleaned.split(/\s+/);
5673
+ if (words.length === 1) {
5674
+ return words[0].slice(0, 2).toUpperCase();
5675
+ }
5676
+ return (words[0][0] + words[1][0]).toUpperCase();
5677
+ }
5678
+ function pad(s, width) {
5679
+ // Strip ANSI escape codes for length measurement so colored cells line up.
5680
+ const visible = s.replace(/\u001b\[[0-9;]*m/g, '');
5681
+ if (visible.length >= width)
5682
+ return s;
5683
+ return s + ' '.repeat(width - visible.length);
5684
+ }
5566
5685
  // ── Test ────────────────────────────────────────────────────
5567
5686
  program
5568
5687
  .command('test')
@@ -8052,6 +8171,151 @@ Examples:
8052
8171
  if (summary.errors > 0)
8053
8172
  process.exit(1);
8054
8173
  });
8174
+ // ── `fazemos dispatch` — role-to-role dispatch ─────────────────────
8175
+ //
8176
+ // Writes an inbox markdown file in the recipient role's operating dir and
8177
+ // calls POST /api/notifications/dispatch so the API can fire any
8178
+ // configured human-filler notifications (Slack today).
8179
+ //
8180
+ // Recipients are resolved via `.fazemos/roles.json` walked up from cwd.
8181
+ // Cross-workspace roles are followed via the local registry's
8182
+ // `cross_workspace_roles` block.
8183
+ //
8184
+ // Example:
8185
+ // fazemos dispatch founder question --from business-strategist \
8186
+ // --body "Should we ship F19 before F7?" --priority high --commit
8187
+ program
8188
+ .command('dispatch <to> <type>')
8189
+ .description(`Write an inbox markdown file in <to>'s operating dir and (optionally) fire
8190
+ notifications via the API.
8191
+
8192
+ Recipients are resolved via the nearest .fazemos/roles.json registry, walking
8193
+ up from the current directory. Cross-workspace recipients are followed via
8194
+ the local registry's cross_workspace_roles block.
8195
+
8196
+ Types: question | task | signal | response | flag | decision | direction`)
8197
+ .requiredOption('--from <role>', 'sender role-slug (required)')
8198
+ .option('--body <text>', 'markdown body of the dispatch (or use --body-file)')
8199
+ .option('--body-file <path>', 'read body from a file')
8200
+ .option('--priority <level>', 'low | normal | high', 'normal')
8201
+ .option('--re <ref>', 'optional reference (worksheet id, file path, etc.)')
8202
+ .option('--thread <id>', 'optional prior item id this responds to')
8203
+ .option('--expires-at <iso>', 'optional deadline (ISO 8601)')
8204
+ .option('--commit', 'git add + commit the inbox file after writing')
8205
+ .option('--no-notify', 'skip the API notification call (file-only)')
8206
+ .action(async (to, type, opts) => {
8207
+ try {
8208
+ // Validate type
8209
+ const allowedTypes = ['question', 'task', 'signal', 'response', 'flag', 'decision', 'direction'];
8210
+ if (!allowedTypes.includes(type)) {
8211
+ throw new Error(`Invalid type "${type}". Allowed: ${allowedTypes.join(', ')}`);
8212
+ }
8213
+ // Resolve body
8214
+ let body = opts.body;
8215
+ if (!body && opts.bodyFile) {
8216
+ body = readFileSync(opts.bodyFile, 'utf-8').trim();
8217
+ }
8218
+ if (!body) {
8219
+ throw new Error('--body or --body-file is required');
8220
+ }
8221
+ // Find local registry
8222
+ const localRegistry = findLocalRegistry(process.cwd());
8223
+ if (!localRegistry) {
8224
+ throw new Error(`No .fazemos/roles.json found in cwd or any parent. ` +
8225
+ `Run from inside a Fazemos-aware workspace.`);
8226
+ }
8227
+ // Resolve recipient
8228
+ const resolved = resolveRole(to, localRegistry);
8229
+ if (!resolved) {
8230
+ throw new Error(`Role "${to}" not found in local registry or any cross-workspace ref. ` +
8231
+ `Check .fazemos/roles.json.`);
8232
+ }
8233
+ const { role, registry } = resolved;
8234
+ const input = {
8235
+ to,
8236
+ from: opts.from,
8237
+ type: type,
8238
+ priority: (opts.priority ?? 'normal'),
8239
+ body,
8240
+ re: opts.re,
8241
+ thread: opts.thread,
8242
+ expiresAt: opts.expiresAt,
8243
+ };
8244
+ // Build + write file
8245
+ const { filename, content, summary } = buildInboxFile(input);
8246
+ const fullPath = writeInboxFile(registry._workspaceRoot, role, filename, content);
8247
+ const relPath = fullPath.startsWith(registry._workspaceRoot)
8248
+ ? fullPath.slice(registry._workspaceRoot.length + 1)
8249
+ : fullPath;
8250
+ console.log(chalk.green(`✓ Wrote inbox file:`));
8251
+ console.log(` ${chalk.cyan(fullPath)}`);
8252
+ console.log(` Workspace: ${registry.workspace} Filler: ${role.filler.identity} (${role.filler.type})`);
8253
+ // Notify (unless --no-notify)
8254
+ if (opts.notify !== false) {
8255
+ try {
8256
+ const payload = buildNotificationPayload(input, role, relPath, summary);
8257
+ const resp = await api('POST', '/api/notifications/dispatch', payload);
8258
+ if (resp.notified) {
8259
+ console.log(chalk.green(`✓ Notification fired: ${resp.channel}`));
8260
+ }
8261
+ else {
8262
+ console.log(chalk.gray(` (no notification fired — ${resp.reason ?? 'unknown'})`));
8263
+ }
8264
+ }
8265
+ catch (err) {
8266
+ const msg = err instanceof Error ? err.message : String(err);
8267
+ console.log(chalk.yellow(` warning: notification call failed — ${msg}`));
8268
+ console.log(chalk.yellow(` (inbox file was still written successfully)`));
8269
+ }
8270
+ }
8271
+ else {
8272
+ console.log(chalk.gray(' (--no-notify; API not called)'));
8273
+ }
8274
+ // Commit if requested
8275
+ if (opts.commit) {
8276
+ try {
8277
+ gitCommitInboxFile(registry._workspaceRoot, relPath, `dispatch(${opts.from} → ${to}): ${type}`);
8278
+ console.log(chalk.green('✓ Committed.'));
8279
+ }
8280
+ catch (err) {
8281
+ const msg = err instanceof Error ? err.message : String(err);
8282
+ console.log(chalk.yellow(` warning: commit failed — ${msg}`));
8283
+ }
8284
+ }
8285
+ }
8286
+ catch (err) {
8287
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
8288
+ process.exit(1);
8289
+ }
8290
+ });
8291
+ program
8292
+ .command('dispatch-list-roles')
8293
+ .description('List roles available in the nearest .fazemos/roles.json registry')
8294
+ .action(() => {
8295
+ const reg = findLocalRegistry(process.cwd());
8296
+ if (!reg) {
8297
+ console.error(chalk.red('No .fazemos/roles.json found in cwd or any parent.'));
8298
+ process.exit(1);
8299
+ }
8300
+ console.log(chalk.cyan(`Registry: ${reg._registryPath}`));
8301
+ console.log(chalk.cyan(`Workspace: ${reg.workspace} Org: ${reg.org}${reg.project ? ' Project: ' + reg.project : ''}`));
8302
+ console.log();
8303
+ console.log(chalk.bold('Local roles:'));
8304
+ for (const [slug, role] of Object.entries(reg.roles)) {
8305
+ const fillerTag = role.filler.type === 'human'
8306
+ ? chalk.yellow(`${role.filler.identity} (human)`)
8307
+ : chalk.gray(`${role.filler.identity} (agent)`);
8308
+ const notifyTag = role.notification ? chalk.green(' [notify]') : '';
8309
+ console.log(` ${chalk.cyan(slug.padEnd(32))} ${fillerTag}${notifyTag}`);
8310
+ }
8311
+ if (reg.cross_workspace_roles) {
8312
+ console.log();
8313
+ console.log(chalk.bold('Cross-workspace roles:'));
8314
+ for (const [slug, xref] of Object.entries(reg.cross_workspace_roles)) {
8315
+ console.log(` ${chalk.cyan(slug.padEnd(32))} → ${xref.workspace_path}`);
8316
+ }
8317
+ }
8318
+ });
8055
8319
  // Skip auto-parse only when running under Vitest (which sets process.env.VITEST).
8056
8320
  // Tests import `program` and drive it via `program.parseAsync(...)` after mocking
8057
8321
  // `./api.js`. In every other context — direct invocation, npx tsx, OR the bin