@formigio/fazemos-cli 0.10.17 → 0.10.19

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,7 +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
+ import { findLocalRegistry, resolveRole, buildInboxFile, writeInboxFile, writeInboxFileAtPath, buildRolesSyncPayload, findUnprocessedInboxFiles, computeFileHash, gitCommitInboxFile, } from './dispatch.js';
14
14
  import { execSync } from 'child_process';
15
15
  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
16
16
  import { fileURLToPath } from 'url';
@@ -8173,21 +8173,26 @@ Examples:
8173
8173
  });
8174
8174
  // ── `fazemos dispatch` — role-to-role dispatch ─────────────────────
8175
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).
8176
+ // F26 data flow (API write-through, Dex S-B):
8177
+ // 1. Resolve recipient via .fazemos/roles.json
8178
+ // 2. POST /api/dispatches (API writes DB row + fires fan-out + returns file_path)
8179
+ // 3. CLI writes inbox file at the returned file_path
8180
+ // 4. --commit git-adds the file
8179
8181
  //
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.
8182
+ // Legacy / degraded mode (--file-only):
8183
+ // Skips the API call and writes only the local file.
8184
+ // Used by not-yet-migrated playbooks and offline work.
8183
8185
  //
8184
8186
  // Example:
8185
8187
  // fazemos dispatch founder question --from business-strategist \
8186
8188
  // --body "Should we ship F19 before F7?" --priority high --commit
8187
8189
  program
8188
8190
  .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
+ .description(`Send a role-to-role dispatch. Calls POST /api/dispatches (API writes DB row
8192
+ + fires fan-out, returns file_path), then CLI writes the inbox file.
8193
+
8194
+ Use --file-only to skip the API and write only the local file (degraded/offline
8195
+ mode, for not-yet-migrated playbooks).
8191
8196
 
8192
8197
  Recipients are resolved via the nearest .fazemos/roles.json registry, walking
8193
8198
  up from the current directory. Cross-workspace recipients are followed via
@@ -8198,11 +8203,11 @@ Types: question | task | signal | response | flag | decision | direction`)
8198
8203
  .option('--body <text>', 'markdown body of the dispatch (or use --body-file)')
8199
8204
  .option('--body-file <path>', 'read body from a file')
8200
8205
  .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')
8206
+ .option('--re <ref>', 'optional reference (worksheet id, feature token, etc.)')
8207
+ .option('--thread <id>', 'optional prior dispatch id this is threaded to')
8203
8208
  .option('--expires-at <iso>', 'optional deadline (ISO 8601)')
8204
8209
  .option('--commit', 'git add + commit the inbox file after writing')
8205
- .option('--no-notify', 'skip the API notification call (file-only)')
8210
+ .option('--file-only', 'skip API call, write only the local inbox file (offline/legacy mode)')
8206
8211
  .action(async (to, type, opts) => {
8207
8212
  try {
8208
8213
  // Validate type
@@ -8241,35 +8246,76 @@ Types: question | task | signal | response | flag | decision | direction`)
8241
8246
  thread: opts.thread,
8242
8247
  expiresAt: opts.expiresAt,
8243
8248
  };
8244
- // Build + write file
8245
8249
  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) {
8250
+ let fullPath;
8251
+ let relPath;
8252
+ if (opts.fileOnly) {
8253
+ // ── Legacy / offline mode: write file directly, skip API ────────
8254
+ fullPath = writeInboxFile(registry._workspaceRoot, role, filename, content);
8255
+ relPath = fullPath.startsWith(registry._workspaceRoot)
8256
+ ? fullPath.slice(registry._workspaceRoot.length + 1)
8257
+ : fullPath;
8258
+ console.log(chalk.green(`✓ Wrote inbox file (--file-only):`));
8259
+ console.log(` ${chalk.cyan(fullPath)}`);
8260
+ console.log(` Workspace: ${registry.workspace} Filler: ${role.filler.identity} (${role.filler.type})`);
8261
+ console.log(chalk.gray(' (API not called — file-only mode)'));
8262
+ }
8263
+ else {
8264
+ // ── F26 API write-through (Dex S-B) ─────────────────────────────
8265
+ const reqBody = {
8266
+ to,
8267
+ from: opts.from,
8268
+ type,
8269
+ priority: opts.priority ?? 'normal',
8270
+ body,
8271
+ thread_id: opts.thread ?? null,
8272
+ re: opts.re ? [opts.re] : null,
8273
+ expires_at: opts.expiresAt ?? null,
8274
+ source: 'cli',
8275
+ };
8276
+ let apiResp;
8255
8277
  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
- }
8278
+ apiResp = await api('POST', '/api/dispatches', reqBody);
8264
8279
  }
8265
8280
  catch (err) {
8281
+ // If API is unavailable, fall back to file-only with a warning
8266
8282
  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)`));
8283
+ console.log(chalk.yellow(` warning: API call failed (${msg}), falling back to --file-only`));
8284
+ fullPath = writeInboxFile(registry._workspaceRoot, role, filename, content);
8285
+ relPath = fullPath.startsWith(registry._workspaceRoot)
8286
+ ? fullPath.slice(registry._workspaceRoot.length + 1)
8287
+ : fullPath;
8288
+ console.log(chalk.green(`✓ Wrote inbox file (fallback):`));
8289
+ console.log(` ${chalk.cyan(fullPath)}`);
8290
+ if (opts.commit) {
8291
+ try {
8292
+ gitCommitInboxFile(registry._workspaceRoot, relPath, `dispatch(${opts.from} → ${to}): ${type}`);
8293
+ console.log(chalk.green('✓ Committed.'));
8294
+ }
8295
+ catch (cerr) {
8296
+ console.log(chalk.yellow(` warning: commit failed — ${cerr instanceof Error ? cerr.message : String(cerr)}`));
8297
+ }
8298
+ }
8299
+ return;
8300
+ }
8301
+ // API returned file_path — CLI writes the actual file (Dex S-B)
8302
+ if (apiResp.file_path) {
8303
+ fullPath = writeInboxFileAtPath(registry._workspaceRoot, apiResp.file_path, content);
8304
+ relPath = apiResp.file_path;
8305
+ }
8306
+ else {
8307
+ // API didn't return a file_path (e.g. cross-workspace agent recipient) — write locally
8308
+ fullPath = writeInboxFile(registry._workspaceRoot, role, filename, content);
8309
+ relPath = fullPath.startsWith(registry._workspaceRoot)
8310
+ ? fullPath.slice(registry._workspaceRoot.length + 1)
8311
+ : fullPath;
8312
+ }
8313
+ console.log(chalk.green(`✓ Dispatch created (id: ${apiResp.id}):`));
8314
+ console.log(` ${chalk.cyan(fullPath)}`);
8315
+ console.log(` Workspace: ${registry.workspace} Filler: ${role.filler.identity} (${role.filler.type})`);
8316
+ if (apiResp.thread_id && apiResp.thread_id !== apiResp.id) {
8317
+ console.log(` Thread: ${apiResp.thread_id}`);
8269
8318
  }
8270
- }
8271
- else {
8272
- console.log(chalk.gray(' (--no-notify; API not called)'));
8273
8319
  }
8274
8320
  // Commit if requested
8275
8321
  if (opts.commit) {
@@ -8288,6 +8334,156 @@ Types: question | task | signal | response | flag | decision | direction`)
8288
8334
  process.exit(1);
8289
8335
  }
8290
8336
  });
8337
+ // ── `fazemos roles sync` — upsert roles.json into role_registrations ──
8338
+ //
8339
+ // F26 (Dex M2): reads cognito_sub from roles.json filler entries and
8340
+ // POSTs to POST /api/role-registrations/sync. Fails loud if a human
8341
+ // filler lacks cognito_sub (required for inbox mapping).
8342
+ program
8343
+ .command('roles sync')
8344
+ .description(`Upsert roles from the nearest .fazemos/roles.json into role_registrations
8345
+ via POST /api/role-registrations/sync.
8346
+
8347
+ Human fillers MUST have cognito_sub set in roles.json (Dex M2). The command
8348
+ fails with a clear error if any human filler is missing it.
8349
+
8350
+ Run after editing roles.json to sync inbox access to the API.`)
8351
+ .option('--org-id <id>', 'explicit org ID (defaults to active org from config)')
8352
+ .option('--project-id <id>', 'explicit project ID (defaults to active project from config)')
8353
+ .option('--dry-run', 'build and print the payload without sending to the API')
8354
+ .action(async (opts) => {
8355
+ try {
8356
+ const localRegistry = findLocalRegistry(process.cwd());
8357
+ if (!localRegistry) {
8358
+ throw new Error(`No .fazemos/roles.json found in cwd or any parent. ` +
8359
+ `Run from inside a Fazemos-aware workspace.`);
8360
+ }
8361
+ const orgId = opts.orgId ?? getActiveOrgId() ?? null;
8362
+ const projectId = opts.projectId ?? getActiveProjectId() ?? null;
8363
+ // buildRolesSyncPayload validates cognito_sub and throws loud if missing
8364
+ const payload = buildRolesSyncPayload(localRegistry, orgId, projectId);
8365
+ if (opts.dryRun) {
8366
+ console.log(chalk.cyan('Dry run — payload that would be sent:'));
8367
+ console.log(JSON.stringify(payload, null, 2));
8368
+ return;
8369
+ }
8370
+ const result = await api('POST', '/api/role-registrations/sync', payload, { noProjectHeader: true });
8371
+ console.log(chalk.green(`✓ Roles synced: ${result.synced} registration(s) upserted`));
8372
+ if (result.roles?.length) {
8373
+ for (const slug of result.roles) {
8374
+ console.log(` ${chalk.cyan(slug)}`);
8375
+ }
8376
+ }
8377
+ }
8378
+ catch (err) {
8379
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
8380
+ process.exit(1);
8381
+ }
8382
+ });
8383
+ // ── `fazemos dispatch-backfill` — phase-B one-time import ────────────
8384
+ //
8385
+ // F26: Imports UNPROCESSED inbox files as dispatches with source='migration'.
8386
+ // Idempotent on content hash — files already imported are skipped.
8387
+ // Used once to seed the DB from the existing file-based inbox history.
8388
+ // NOTE: hyphenated to avoid Commander conflict with `dispatch <to> <type>`.
8389
+ program
8390
+ .command('dispatch-backfill')
8391
+ .description(`Phase-B one-time import of unprocessed inbox files as dispatches
8392
+ (status=unread, source=migration). Idempotent: files already imported
8393
+ are skipped (content-hash dedup). Skips any file that has a corresponding
8394
+ entry in the role's processed/ directory.
8395
+
8396
+ Run from the workspace root where roles/*/inbox/ directories live.`)
8397
+ .option('--roles-dir <path>', 'relative path to roles directory', 'roles')
8398
+ .option('--dry-run', 'list files that would be imported without sending to API')
8399
+ .option('--limit <n>', 'max files to import in one run', (v) => parseInt(v, 10))
8400
+ .action(async (opts) => {
8401
+ try {
8402
+ const localRegistry = findLocalRegistry(process.cwd());
8403
+ if (!localRegistry) {
8404
+ throw new Error(`No .fazemos/roles.json found in cwd or any parent. ` +
8405
+ `Run from inside a Fazemos-aware workspace.`);
8406
+ }
8407
+ const files = findUnprocessedInboxFiles(localRegistry._workspaceRoot, opts.rolesDir ?? 'roles');
8408
+ if (files.length === 0) {
8409
+ console.log(chalk.gray('No unprocessed inbox files found.'));
8410
+ return;
8411
+ }
8412
+ const limit = opts.limit ?? files.length;
8413
+ const toProcess = files.slice(0, limit);
8414
+ if (opts.dryRun) {
8415
+ console.log(chalk.cyan(`Dry run — ${toProcess.length} file(s) would be imported:`));
8416
+ for (const f of toProcess) {
8417
+ const hash = computeFileHash(f.content);
8418
+ console.log(` ${chalk.gray(hash.slice(0, 8))} ${f.relativePath}`);
8419
+ }
8420
+ return;
8421
+ }
8422
+ let imported = 0;
8423
+ let skipped = 0;
8424
+ const errors = [];
8425
+ for (const f of toProcess) {
8426
+ try {
8427
+ // Extract frontmatter fields for the API call
8428
+ const fmMatch = f.content.match(/^---\n([\s\S]*?)\n---/);
8429
+ let from = f.rolePath; // fallback: role dir name
8430
+ let to = f.rolePath;
8431
+ let type = 'signal';
8432
+ let priority = 'normal';
8433
+ if (fmMatch) {
8434
+ const fm = fmMatch[1];
8435
+ const fromM = fm.match(/^from:\s*(.+)$/m);
8436
+ const toM = fm.match(/^to:\s*(.+)$/m);
8437
+ const typeM = fm.match(/^type:\s*(.+)$/m);
8438
+ const prioM = fm.match(/^priority:\s*(.+)$/m);
8439
+ if (fromM)
8440
+ from = fromM[1].trim();
8441
+ if (toM)
8442
+ to = toM[1].trim();
8443
+ if (typeM)
8444
+ type = typeM[1].trim();
8445
+ if (prioM)
8446
+ priority = prioM[1].trim();
8447
+ }
8448
+ // Body = content after the closing ---
8449
+ const bodyMatch = f.content.match(/^---\n[\s\S]*?\n---\n\n?([\s\S]*)$/);
8450
+ const body = bodyMatch ? bodyMatch[1].trim() : f.content.trim();
8451
+ const reqBody = {
8452
+ to,
8453
+ from,
8454
+ type,
8455
+ priority,
8456
+ body,
8457
+ no_notify: true, // backfill is historical — never fire Slack fan-out
8458
+ source: 'migration',
8459
+ };
8460
+ await api('POST', '/api/dispatches', reqBody);
8461
+ imported++;
8462
+ console.log(chalk.green(` ✓ ${f.relativePath}`));
8463
+ }
8464
+ catch (err) {
8465
+ const msg = err instanceof Error ? err.message : String(err);
8466
+ // 409 = already imported (content hash dedup on server side)
8467
+ if (msg.includes('409') || msg.toLowerCase().includes('already imported')) {
8468
+ skipped++;
8469
+ console.log(chalk.gray(` ~ ${f.relativePath} (already imported)`));
8470
+ }
8471
+ else {
8472
+ errors.push(`${f.relativePath}: ${msg}`);
8473
+ console.log(chalk.yellow(` ✗ ${f.relativePath}: ${msg}`));
8474
+ }
8475
+ }
8476
+ }
8477
+ console.log();
8478
+ console.log(`Backfill complete: ${chalk.green(String(imported))} imported, ${chalk.gray(String(skipped))} skipped, ${errors.length > 0 ? chalk.red(String(errors.length)) : '0'} errors`);
8479
+ if (errors.length > 0)
8480
+ process.exit(1);
8481
+ }
8482
+ catch (err) {
8483
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
8484
+ process.exit(1);
8485
+ }
8486
+ });
8291
8487
  program
8292
8488
  .command('dispatch-list-roles')
8293
8489
  .description('List roles available in the nearest .fazemos/roles.json registry')