@formigio/fazemos-cli 0.10.50 → 0.10.51

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
@@ -1294,6 +1294,72 @@ function readScopeOpts(opts) {
1294
1294
  return { projectSlug: opts.project };
1295
1295
  return {};
1296
1296
  }
1297
+ /**
1298
+ * P-TOOL-1 — render a rematerialize reconciliation plan (preview or applied).
1299
+ *
1300
+ * The plan has five step-disposition buckets:
1301
+ * preserved completed/skipped steps kept frozen
1302
+ * rematerialized pending/blocked/failed steps re-derived from the new def
1303
+ * inserted brand-new template steps
1304
+ * removed step_instances absent from the new def
1305
+ * reset completed downstream steps that --reset-downstream-of-new
1306
+ * will RE-RUN (empty unless the flag is set) — the
1307
+ * preview/apply-fidelity bucket, surfaced under "will RE-RUN".
1308
+ *
1309
+ * Each StepDisposition is { step_instance_id, template_step_id, step_name,
1310
+ * action, from_status, to_status }. Warnings render yellow; blockers render red.
1311
+ */
1312
+ function renderRematerializePlan(plan, warnings, blockers, render) {
1313
+ // process.stderr for the blocked-path so the plan travels with the error;
1314
+ // process.stdout otherwise. Commander's console.log/console.error split.
1315
+ const out = render.blockedHeader ? console.error : console.log;
1316
+ const p = plan ?? {};
1317
+ const buckets = [
1318
+ ['preserved', Array.isArray(p.preserved) ? p.preserved : []],
1319
+ ['rematerialized', Array.isArray(p.rematerialized) ? p.rematerialized : []],
1320
+ ['inserted', Array.isArray(p.inserted) ? p.inserted : []],
1321
+ ['removed', Array.isArray(p.removed) ? p.removed : []],
1322
+ ['reset', Array.isArray(p.reset) ? p.reset : []],
1323
+ ];
1324
+ // Count summary line.
1325
+ const counts = buckets.map(([name, rows]) => `${name}: ${rows.length}`).join(' | ');
1326
+ out(chalk.gray(` ${counts}`));
1327
+ // Per-step table, grouped by disposition. reset[] gets a distinct
1328
+ // "will RE-RUN" heading so an operator sees what apply will actually do.
1329
+ const rowLine = (d) => {
1330
+ const name = d?.step_name ?? d?.template_step_id ?? '(unknown)';
1331
+ const from = d?.from_status ?? '—';
1332
+ const to = d?.to_status ?? '—';
1333
+ return from === to ? ` ${name} [${to}]` : ` ${name} ${from} → ${to}`;
1334
+ };
1335
+ for (const [name, rows] of buckets) {
1336
+ if (rows.length === 0)
1337
+ continue;
1338
+ if (name === 'reset') {
1339
+ out(chalk.magenta(` will RE-RUN (reset ${rows.length}):`));
1340
+ }
1341
+ else {
1342
+ out(chalk.cyan(` ${name} (${rows.length}):`));
1343
+ }
1344
+ for (const d of rows)
1345
+ out(rowLine(d));
1346
+ }
1347
+ const warns = Array.isArray(warnings) ? warnings : [];
1348
+ if (warns.length) {
1349
+ out(chalk.yellow(` warnings (${warns.length}):`));
1350
+ for (const w of warns) {
1351
+ out(chalk.yellow(` - ${w?.code ?? '?'}${w?.template_step_id ? ` [${w.template_step_id}]` : ''}: ${w?.detail ?? ''}`));
1352
+ }
1353
+ }
1354
+ const blocks = Array.isArray(blockers) ? blockers : [];
1355
+ if (blocks.length) {
1356
+ out(chalk.red(` blockers (${blocks.length}):`));
1357
+ for (const b of blocks) {
1358
+ const offending = Array.isArray(b?.offending) && b.offending.length ? ` (${b.offending.join(', ')})` : '';
1359
+ out(chalk.red(` - ${b?.code ?? '?'}: ${b?.detail ?? ''}${offending}`));
1360
+ }
1361
+ }
1362
+ }
1297
1363
  /**
1298
1364
  * Uniform error handler for scoped commands. When the API emits
1299
1365
  * MISSING_PROJECT_CONTEXT (§7.3.1), the api helper has already re-shaped
@@ -5419,6 +5485,132 @@ pipelines
5419
5485
  process.exit(1);
5420
5486
  }
5421
5487
  });
5488
+ // ── P-TOOL-1 — `pl rematerialize <instanceId>` ──
5489
+ // Rebase a live pipeline instance onto the CURRENT template definition without
5490
+ // losing completed-step state. Safe-by-default: WITHOUT --apply the verb runs a
5491
+ // preview (dry_run=true) and prints the reconciliation plan, mutating nothing.
5492
+ //
5493
+ // POST /api/pipeline-instances/:id/rematerialize
5494
+ // Body : { dry_run | apply, reason?, force_inflight?, reset_downstream_of_new?, keep_failed? }
5495
+ // Header : If-Match: "<n>" (opt-in via --expect-version; default bypass)
5496
+ //
5497
+ // The API is itself safe-by-default (dry_run defaults to true server-side), so a
5498
+ // bare call previews. We are explicit anyway: send { dry_run: true } on preview
5499
+ // and { apply: true } on --apply. Owner/admin-only for apply and for the
5500
+ // dangerous --force-inflight / --reset-downstream-of-new flags (D5 SPLIT).
5501
+ pipelines
5502
+ .command('rematerialize')
5503
+ .description('Rebase a live pipeline instance onto the current template definition, preserving completed-step state. ' +
5504
+ 'Safe default: WITHOUT --apply it previews the reconciliation plan (mutates nothing). ' +
5505
+ 'With --apply it reconciles the instance in one transaction. Apply (and the --force-inflight / ' +
5506
+ '--reset-downstream-of-new flags) require org owner or admin.')
5507
+ .argument('<instanceId>', 'Pipeline instance ID')
5508
+ .option('--apply', 'Perform the mutation. WITHOUT it the verb previews (dry_run=true) and prints the plan.')
5509
+ .option('--force-inflight', 'Cancel in-flight step executions and reconcile anyway (D3 override). Owner/admin only.')
5510
+ .option('--reset-downstream-of-new', 'Cascade-reset completed steps downstream of a newly-inserted step so they re-run (D6 override). Owner/admin only.')
5511
+ .option('--keep-failed', 'Leave failed steps in "failed" instead of recovering them to "pending" (D4 opt-out).')
5512
+ .option('-r, --reason <reason>', 'Free-form audit reason (recorded in audit_log on apply).')
5513
+ .option('--expect-version <n>', 'Optimistic-concurrency guard: send If-Match: "<n>" so the API rejects the call (409 VERSION_CONFLICT) ' +
5514
+ 'if the current instance version differs. Omit to bypass version checking (default).')
5515
+ .option('--json', 'Print the raw API response as JSON (machine-readable)')
5516
+ .action(async (instanceId, opts) => {
5517
+ try {
5518
+ // Body field names match the API contract verbatim:
5519
+ // dry_run / apply — mode select (preview vs mutate)
5520
+ // reason — optional audit string (apply only, but harmless on preview)
5521
+ // force_inflight — D3 override
5522
+ // reset_downstream_of_new — D6 override
5523
+ // keep_failed — D4 opt-out
5524
+ const apply = opts.apply === true;
5525
+ const body = apply ? { apply: true } : { dry_run: true };
5526
+ if (opts.forceInflight)
5527
+ body.force_inflight = true;
5528
+ if (opts.resetDownstreamOfNew)
5529
+ body.reset_downstream_of_new = true;
5530
+ if (opts.keepFailed)
5531
+ body.keep_failed = true;
5532
+ if (opts.reason)
5533
+ body.reason = opts.reason;
5534
+ // --expect-version <n> opts the caller in to optimistic concurrency.
5535
+ // Default is bypass (no header), mirroring force-transition Decision #4.
5536
+ const apiOpts = {};
5537
+ if (opts.expectVersion !== undefined) {
5538
+ const n = Number(opts.expectVersion);
5539
+ if (!Number.isInteger(n)) {
5540
+ console.error(chalk.red(`--expect-version must be an integer; got "${opts.expectVersion}"`));
5541
+ process.exit(1);
5542
+ }
5543
+ apiOpts.headers = { 'If-Match': String(n) };
5544
+ }
5545
+ const path = `/api/pipeline-instances/${instanceId}/rematerialize`;
5546
+ let data;
5547
+ try {
5548
+ data = (await api('POST', path, body, apiOpts));
5549
+ }
5550
+ catch (err) {
5551
+ // 409 REMATERIALIZE_BLOCKED carries the plan + per-step blockers in its
5552
+ // body; surface them so the operator sees exactly what to fix.
5553
+ if (err instanceof ApiError && err.code === 'REMATERIALIZE_BLOCKED') {
5554
+ if (opts.json) {
5555
+ console.log(JSON.stringify(err.body ?? { code: err.code, error: err.message }, null, 2));
5556
+ process.exit(1);
5557
+ }
5558
+ const b = (err.body ?? {});
5559
+ console.error(chalk.red(`Rematerialize BLOCKED — instance ${instanceId} (template v${b.from_version} → v${b.to_version})`));
5560
+ renderRematerializePlan(b.plan, b.warnings, b.blockers, { blockedHeader: true });
5561
+ console.error(chalk.red('\nResolve the blockers above and retry.'));
5562
+ process.exit(1);
5563
+ }
5564
+ // 409 VERSION_CONFLICT — the If-Match pin did not match the live version.
5565
+ if (err instanceof ApiError && err.code === 'VERSION_CONFLICT') {
5566
+ console.error(chalk.red(`Version conflict: the instance was modified concurrently (--expect-version mismatch). ${err.message}`));
5567
+ process.exit(1);
5568
+ }
5569
+ // 403 owner/admin gate on apply / dangerous flags (or project-only agent).
5570
+ if (err instanceof ApiError && err.status === 403) {
5571
+ console.error(chalk.red(`Permission denied: ${err.message}`));
5572
+ process.exit(1);
5573
+ }
5574
+ throw err;
5575
+ }
5576
+ if (opts.json) {
5577
+ console.log(JSON.stringify(data, null, 2));
5578
+ return;
5579
+ }
5580
+ // 200 ALREADY_CURRENT — idempotent no-op (returned as 200, not an error).
5581
+ if (data?.already_current === true) {
5582
+ console.log(chalk.green(`Already current: instance ${instanceId} is already on template v${data.to_version}. No changes made.`));
5583
+ return;
5584
+ }
5585
+ if (data?.dry_run === true) {
5586
+ // Preview mode — print the plan and the run-again hint.
5587
+ console.log(chalk.bold(`Rematerialize preview — instance ${instanceId} (template v${data.from_version} → v${data.to_version})`));
5588
+ renderRematerializePlan(data.plan, data.warnings, data.blockers, {});
5589
+ if (Array.isArray(data.blockers) && data.blockers.length > 0) {
5590
+ console.log(chalk.yellow('\nThis plan has blockers — apply would be refused (409 REMATERIALIZE_BLOCKED) until they are resolved.'));
5591
+ }
5592
+ console.log(chalk.gray('\nRun again with --apply to execute.'));
5593
+ return;
5594
+ }
5595
+ // Applied — print the applied summary + forensic echo.
5596
+ console.log(chalk.green(`Rematerialized instance ${instanceId} (template v${data.from_version} → v${data.to_version})`));
5597
+ renderRematerializePlan(data.plan, data.warnings, data.blockers, {});
5598
+ if (data?.audit_log_id) {
5599
+ console.log(chalk.gray(` audit_log_id: ${data.audit_log_id}`));
5600
+ }
5601
+ const queued = Array.isArray(data?.queued_step_ids) ? data.queued_step_ids : [];
5602
+ if (queued.length) {
5603
+ console.log(chalk.gray(` queued_step_ids (${queued.length}): ${queued.join(', ')}`));
5604
+ }
5605
+ else {
5606
+ console.log(chalk.gray(' queued_step_ids: (none)'));
5607
+ }
5608
+ }
5609
+ catch (err) {
5610
+ console.error(chalk.red(err.message));
5611
+ process.exit(1);
5612
+ }
5613
+ });
5422
5614
  pipelines
5423
5615
  .command('set-params')
5424
5616
  .description('Set instance parameters (atomic update)')