@revturbine/cli 0.4.0 → 0.5.1

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +373 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,7 +63,7 @@ Commands that read a config name the version explicitly — there is no default:
63
63
  | `upload` | Stage a Config File as the open draft. |
64
64
  | `launch` | Take a config live: validate (launch gate) → submit → approve → deploy. `launch <file>` or `launch --draft`. |
65
65
  | `discard` | Archive the open draft (`--yes`). |
66
- | `rollback` | Revert a deployed playbook version (creates a reverting draft; becomes `restore` once server-side entry copying lands). |
66
+ | `restore` | Stage a draft that restores a past release from its frozen snapshot; `--launch` takes it live. Halts if a draft is open. |
67
67
  | `status` | The live Release and the open draft, side by side. |
68
68
  | `history` | The Release Version Log, newest first. |
69
69
  | `preview` | The open draft's staged changes. |
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { createRequire } from "module";
7
7
  import { createInterface } from "readline/promises";
8
8
  import { spawn as spawn2 } from "child_process";
9
9
  import { Command, CommanderError, Option } from "commander";
10
- import { z as z27 } from "zod";
10
+ import { z as z29 } from "zod";
11
11
 
12
12
  // src/schema/exported-config.snapshot.mjs
13
13
  import { z as z2 } from "zod";
@@ -5223,6 +5223,344 @@ var AuthSsoProviderSchema = IdField.extend({
5223
5223
  domain: z26.string().min(1).meta(Unrestricted23)
5224
5224
  }).meta({ id: "AuthSsoProvider", "x-revturbine-schema-persistence": Persisted23, "x-revturbine-schema-exposure": Internal19 });
5225
5225
 
5226
+ // src/schema/validators.snapshot.mjs
5227
+ import { z as z27 } from "zod";
5228
+ import { z as z28 } from "zod";
5229
+ var SeveritySchema2 = z27.enum([
5230
+ "error_draft",
5231
+ "error_launch",
5232
+ "warning",
5233
+ "ai_check"
5234
+ ]);
5235
+ var CallSiteSchema = z27.enum([
5236
+ "studio",
5237
+ // inline studio editing / modal commit
5238
+ "publish",
5239
+ // the launch gate (literal kept as 'publish' — see NOTE above)
5240
+ "ingestion",
5241
+ // external ingestion — CLI / MCP / agents
5242
+ "compile"
5243
+ // compile / activate backstop
5244
+ ]);
5245
+ var TargetRefSchema = z27.object({
5246
+ object_type: z27.string().optional(),
5247
+ object_id: z27.string().optional(),
5248
+ field: z27.string().optional(),
5249
+ /** Studio key the UI resolves a deep-link against. */
5250
+ studio: z27.string().optional(),
5251
+ /** A free-form deep-link target the UI can resolve into a URL. */
5252
+ href: z27.string().optional(),
5253
+ /** Zod issue path, for structural-tier findings. */
5254
+ path: z27.array(z27.union([z27.string(), z27.number()])).optional()
5255
+ });
5256
+ var ValidationFindingSchema = z27.object({
5257
+ /** Stable rule ID (e.g. `VAL-PLN-01`); for schema findings, the Zod issue code. */
5258
+ code: z27.string(),
5259
+ severity: SeveritySchema2,
5260
+ targetRef: TargetRefSchema,
5261
+ /** Single user-facing message — same string at the gate, modal, and CLI. */
5262
+ message: z27.string(),
5263
+ /** Pointer to the originating spec rule. Beta inspection aid — remove post-beta. */
5264
+ specRef: z27.string().optional(),
5265
+ /** Optional extended context (preserved from the old `ValidationIssue.detail`). */
5266
+ detail: z27.string().optional(),
5267
+ /**
5268
+ * Set by `evaluate` when a `focus` is supplied and this finding touches the
5269
+ * focused object — surfaces use it to spotlight inline. Never narrows what is
5270
+ * checked (spec §3.1).
5271
+ */
5272
+ spotlight: z27.boolean().optional()
5273
+ });
5274
+ var CATALOG = {
5275
+ // no-Stripe-price. Interim `warning` (does not block): a plan price that has
5276
+ // all its billing info but is entered statically rather than synced from
5277
+ // Stripe is valid to launch — it should warn, not block. The stricter
5278
+ // three-way model (block if billing info is INCOMPLETE; warn if complete-but-
5279
+ // static; pass if complete-and-Stripe-connected) is a tracked follow-up
5280
+ // (static-pricing plan). Spec §5.3's blanket error_launch is superseded by
5281
+ // that follow-up; see the plan.
5282
+ "VAL-PLN-01": {
5283
+ id: "VAL-PLN-01",
5284
+ severity: "warning",
5285
+ message: "Plan '{plan}' has no Stripe price linked.",
5286
+ specRef: "optimization-studio-ui.md \xA73.1; plans-entitlements-studio-ui.md \xA72.2"
5287
+ },
5288
+ // entitlement-rule overlap. Origin: the `rule.overlap` check (plan 40).
5289
+ "VAL-PLN-05": {
5290
+ id: "VAL-PLN-05",
5291
+ severity: "warning",
5292
+ message: "These entitlement rules target the same plan and segment \u2014 only one will apply.",
5293
+ specRef: "config-validation.md \xA75.3 (provisional \u2014 plan 73 Q-1)"
5294
+ },
5295
+ // multiple public variations. Origin: `*_variation.multiple_public`.
5296
+ "VAL-PLN-06": {
5297
+ id: "VAL-PLN-06",
5298
+ severity: "error_draft",
5299
+ message: "Only one public variation is allowed per billing period and segment.",
5300
+ specRef: "config-validation.md \xA75.3 (provisional \u2014 plan 73 Q-1)"
5301
+ }
5302
+ };
5303
+ function getCatalogEntry(id) {
5304
+ return CATALOG[id];
5305
+ }
5306
+ function runSemanticRules(graph) {
5307
+ return [
5308
+ ...checkPlansHaveStripePrice(graph),
5309
+ ...checkRuleOverlaps(graph),
5310
+ ...checkPublicVariationCollisions(graph)
5311
+ ];
5312
+ }
5313
+ function finding(catalogId, target, opts = {}) {
5314
+ const entry = getCatalogEntry(catalogId);
5315
+ const severity = entry?.severity ?? "warning";
5316
+ return {
5317
+ code: catalogId,
5318
+ severity,
5319
+ targetRef: target,
5320
+ message: opts.message ?? entry?.message ?? catalogId,
5321
+ ...entry?.specRef ? { specRef: entry.specRef } : {},
5322
+ ...opts.detail ? { detail: opts.detail } : {}
5323
+ };
5324
+ }
5325
+ function checkPlansHaveStripePrice(graph) {
5326
+ const plans = graph.plans;
5327
+ if (!plans?.length) return [];
5328
+ const plansWithVariations = /* @__PURE__ */ new Set();
5329
+ const pricedPlanIds = /* @__PURE__ */ new Set();
5330
+ for (const v of graph.plan_variations ?? []) {
5331
+ const planId = String(v.plan_id ?? "");
5332
+ if (!planId) continue;
5333
+ plansWithVariations.add(planId);
5334
+ const priceId = v.stripe_price_id;
5335
+ if (typeof priceId === "string" && priceId.length > 0) pricedPlanIds.add(planId);
5336
+ }
5337
+ const findings = [];
5338
+ for (const row of plans) {
5339
+ const planKeys = [String(row.handle ?? ""), String(row.id ?? "")].filter(Boolean);
5340
+ const hasVariations = planKeys.some((k) => plansWithVariations.has(k));
5341
+ if (!hasVariations) continue;
5342
+ const hasStripePrice = planKeys.some((k) => pricedPlanIds.has(k));
5343
+ if (hasStripePrice) continue;
5344
+ const id = String(row.handle ?? row.id ?? "");
5345
+ const name = String(row.name ?? id);
5346
+ findings.push(
5347
+ finding(
5348
+ "VAL-PLN-01",
5349
+ {
5350
+ object_type: "plan",
5351
+ object_id: id,
5352
+ field: "plan_variations",
5353
+ studio: "plans-entitlements"
5354
+ },
5355
+ {
5356
+ message: `Plan '${name}' has no Stripe price linked.`,
5357
+ detail: "Link a Plan Variation to a Stripe Price before activating the plan for paid customers."
5358
+ }
5359
+ )
5360
+ );
5361
+ }
5362
+ return findings;
5363
+ }
5364
+ function checkRuleOverlaps(graph) {
5365
+ const rules = graph.entitlement_rules;
5366
+ if (!rules?.length) return [];
5367
+ const segmentDimensions = /* @__PURE__ */ new Map();
5368
+ for (const seg of graph.segments ?? []) {
5369
+ const segId = String(seg.handle ?? seg.id ?? "");
5370
+ const dim = typeof seg.dimension_id === "string" ? seg.dimension_id : "";
5371
+ if (segId) segmentDimensions.set(segId, dim);
5372
+ }
5373
+ const sigs = [];
5374
+ for (const rule of rules) {
5375
+ const id = String(rule.handle ?? rule.id ?? "");
5376
+ const targetIds = /* @__PURE__ */ new Set();
5377
+ if (Array.isArray(rule.targets)) {
5378
+ for (const t of rule.targets) {
5379
+ if (t && typeof t === "object") {
5380
+ const obj = t;
5381
+ if (typeof obj.id === "string") {
5382
+ const kind = typeof obj.kind === "string" ? obj.kind : "plan";
5383
+ targetIds.add(`${kind}:${obj.id}`);
5384
+ }
5385
+ }
5386
+ }
5387
+ } else if (Array.isArray(rule.plan_ids)) {
5388
+ for (const pid of rule.plan_ids) {
5389
+ if (typeof pid === "string") targetIds.add(`plan:${pid}`);
5390
+ }
5391
+ } else if (rule.plan_id !== void 0 && rule.plan_id !== null) {
5392
+ targetIds.add(`plan:${String(rule.plan_id)}`);
5393
+ }
5394
+ const segmentIds = Array.isArray(rule.segment_ids) ? rule.segment_ids.filter((s) => typeof s === "string") : [];
5395
+ const segmentPairs = /* @__PURE__ */ new Set();
5396
+ for (const segId of segmentIds) {
5397
+ const dim = segmentDimensions.get(segId) ?? "";
5398
+ segmentPairs.add(`${dim}::${segId}`);
5399
+ }
5400
+ sigs.push({
5401
+ rule,
5402
+ id,
5403
+ targetIds,
5404
+ segmentPairs,
5405
+ matchesAllSegments: segmentIds.length === 0
5406
+ });
5407
+ }
5408
+ const overlapping = /* @__PURE__ */ new Set();
5409
+ for (let i = 0; i < sigs.length; i++) {
5410
+ for (let j = i + 1; j < sigs.length; j++) {
5411
+ const a = sigs[i];
5412
+ const b = sigs[j];
5413
+ let sharedTarget = false;
5414
+ for (const t of a.targetIds) {
5415
+ if (b.targetIds.has(t)) {
5416
+ sharedTarget = true;
5417
+ break;
5418
+ }
5419
+ }
5420
+ if (!sharedTarget) continue;
5421
+ let overlaps = false;
5422
+ if (a.matchesAllSegments || b.matchesAllSegments) {
5423
+ overlaps = true;
5424
+ } else {
5425
+ for (const pair of a.segmentPairs) {
5426
+ if (b.segmentPairs.has(pair)) {
5427
+ overlaps = true;
5428
+ break;
5429
+ }
5430
+ }
5431
+ }
5432
+ if (overlaps) {
5433
+ overlapping.add(i);
5434
+ overlapping.add(j);
5435
+ }
5436
+ }
5437
+ }
5438
+ const findings = [];
5439
+ for (const idx of overlapping) {
5440
+ const sig = sigs[idx];
5441
+ const name = String(sig.rule.name ?? sig.id);
5442
+ findings.push(
5443
+ finding(
5444
+ "VAL-PLN-05",
5445
+ {
5446
+ object_type: "entitlement_rule",
5447
+ object_id: sig.id,
5448
+ studio: "plans-entitlements"
5449
+ },
5450
+ {
5451
+ message: `Entitlement rule '${name}' overlaps another rule on a shared target and segment \u2014 only one will apply.`,
5452
+ detail: "Where rules overlap, the most permissive value applies (plans-entitlements-studio-ui.md \xA72.3.2). Adjust the segment selection if this is unintended."
5453
+ }
5454
+ )
5455
+ );
5456
+ }
5457
+ return findings;
5458
+ }
5459
+ function checkPublicVariationCollisions(graph) {
5460
+ return [
5461
+ ...collectPublicCollisions(graph.plan_variations, "plan_variation", "plan_id"),
5462
+ ...collectPublicCollisions(graph.addon_variations, "addon_variation", "addon_id")
5463
+ ];
5464
+ }
5465
+ function collectPublicCollisions(rows, objectType, parentField) {
5466
+ if (!rows?.length) return [];
5467
+ const publicByTuple = /* @__PURE__ */ new Map();
5468
+ for (const row of rows) {
5469
+ if (row.is_current === false) continue;
5470
+ if (String(row.visibility ?? "public") !== "public") continue;
5471
+ const tenant = String(row.tenant_id ?? "");
5472
+ const parent = String(row[parentField] ?? "");
5473
+ const period = String(row.billing_period ?? "");
5474
+ const segment = row.segment_id == null ? "_default" : String(row.segment_id);
5475
+ const key = `${tenant} ${parent} ${period} ${segment}`;
5476
+ const id = String(row.handle ?? row.id ?? "");
5477
+ const bucket = publicByTuple.get(key);
5478
+ if (bucket) bucket.ids.push(id);
5479
+ else publicByTuple.set(key, { ids: [id], parent, period, segment });
5480
+ }
5481
+ const findings = [];
5482
+ for (const bucket of publicByTuple.values()) {
5483
+ if (bucket.ids.length < 2) continue;
5484
+ const allIds = [...bucket.ids].sort();
5485
+ const segmentLabel = bucket.segment === "_default" ? "no segment" : `segment ${bucket.segment}`;
5486
+ const parentLabel = parentField === "plan_id" ? "plan" : "add-on";
5487
+ for (const id of bucket.ids) {
5488
+ findings.push(
5489
+ finding(
5490
+ "VAL-PLN-06",
5491
+ {
5492
+ object_type: objectType,
5493
+ object_id: id,
5494
+ field: "visibility",
5495
+ studio: "plans-entitlements"
5496
+ },
5497
+ {
5498
+ message: `Variation '${id}' is one of ${bucket.ids.length} marked Public for the same ${parentLabel}, billing period '${bucket.period}', and ${segmentLabel} \u2014 only one may be Public.`,
5499
+ detail: `Set all but one of [${allIds.join(", ")}] to Unlisted or Legacy before deploying.`
5500
+ }
5501
+ )
5502
+ );
5503
+ }
5504
+ }
5505
+ return findings;
5506
+ }
5507
+ function fieldLabel(path3) {
5508
+ if (!path3 || path3.length === 0) return "This value";
5509
+ return String(path3[path3.length - 1]);
5510
+ }
5511
+ function messageForZodIssue(issue) {
5512
+ const field = fieldLabel(issue.path);
5513
+ switch (issue.code) {
5514
+ case "invalid_type":
5515
+ return issue.input === void 0 ? `${field} is required.` : `${field} must be a ${issue.expected ?? "valid value"}.`;
5516
+ case "invalid_value":
5517
+ return `${field} must be one of the allowed values.`;
5518
+ case "invalid_format":
5519
+ return `${field} isn't in a valid format.`;
5520
+ case "too_small":
5521
+ return `${field} is too small.`;
5522
+ case "too_big":
5523
+ return `${field} is too large.`;
5524
+ case "unrecognized_keys":
5525
+ return `${field} isn't a recognized setting.`;
5526
+ default:
5527
+ return issue.message ?? `${field} is invalid.`;
5528
+ }
5529
+ }
5530
+ function targetRefForIssue(issue, basePath) {
5531
+ const path3 = [...basePath, ...issue.path ?? []].map(
5532
+ (seg) => typeof seg === "number" ? seg : String(seg)
5533
+ );
5534
+ return { path: path3 };
5535
+ }
5536
+ function zodErrorToFindings(error, opts = {}) {
5537
+ const basePath = opts.basePath ?? [];
5538
+ return error.issues.map((issue) => ({
5539
+ code: issue.code ?? "invalid",
5540
+ severity: "error_draft",
5541
+ targetRef: targetRefForIssue(issue, basePath),
5542
+ message: messageForZodIssue(issue),
5543
+ ...opts.specRef ? { specRef: opts.specRef } : {}
5544
+ }));
5545
+ }
5546
+ function spotlights(finding2, focus) {
5547
+ if (!focus) return false;
5548
+ const { object_type, object_id } = finding2.targetRef;
5549
+ if (focus.object_id != null && object_id !== focus.object_id) return false;
5550
+ if (focus.object_type != null && object_type !== focus.object_type) return false;
5551
+ return focus.object_id != null || focus.object_type != null;
5552
+ }
5553
+ function evaluate(graph, opts = {}) {
5554
+ const findings = [];
5555
+ if (opts.structuralErrors) {
5556
+ const errors = Array.isArray(opts.structuralErrors) ? opts.structuralErrors : [opts.structuralErrors];
5557
+ for (const error of errors) findings.push(...zodErrorToFindings(error));
5558
+ }
5559
+ findings.push(...runSemanticRules(graph));
5560
+ if (!opts.focus) return findings;
5561
+ return findings.map((f) => spotlights(f, opts.focus) ? { ...f, spotlight: true } : f);
5562
+ }
5563
+
5226
5564
  // src/schema/version.ts
5227
5565
  var SCHEMA_VERSION = "0.1.107";
5228
5566
 
@@ -5556,8 +5894,8 @@ function formatDiff(diff) {
5556
5894
 
5557
5895
  // src/lib/config-validate.ts
5558
5896
  var BLOCKING_SEVERITIES = /* @__PURE__ */ new Set(["error_draft", "error_launch"]);
5559
- function isBlockingFinding(finding) {
5560
- return BLOCKING_SEVERITIES.has(finding.severity);
5897
+ function isBlockingFinding(finding2) {
5898
+ return BLOCKING_SEVERITIES.has(finding2.severity);
5561
5899
  }
5562
5900
  function hasBlockingFindings(findings) {
5563
5901
  return findings.some(isBlockingFinding);
@@ -5890,7 +6228,7 @@ Command groups:
5890
6228
  Auth & meta login, logout, signup, whoami, schema, docs
5891
6229
  Download download
5892
6230
  Check validate, diff, show
5893
- Stage/launch upload, launch, discard, rollback
6231
+ Stage/launch upload, launch, discard, restore
5894
6232
  Inspect status, history, preview, evaluate
5895
6233
 
5896
6234
  Version selectors (no defaults \u2014 a command that reads a config requires one):
@@ -5916,7 +6254,7 @@ Common workflows:
5916
6254
  # Inspect and roll back
5917
6255
  revturbine status
5918
6256
  revturbine history
5919
- revturbine rollback <playbook-version-id>
6257
+ revturbine restore <playbook-version-id> --launch
5920
6258
 
5921
6259
  Exit-code classes: 0 ok \xB7 1 unexpected \xB7 2 usage \xB7 3 auth \xB7 4 validation
5922
6260
  blocked \xB7 5 conflict/stale \xB7 6 network \xB7 7 server error.
@@ -5924,7 +6262,7 @@ blocked \xB7 5 conflict/stale \xB7 6 network \xB7 7 server error.
5924
6262
  Auth:
5925
6263
  Most commands need a token \u2014 run \`login\` first. Credentials live at
5926
6264
  ~/.revturbine/credentials.json (0600); the token's tenant is used by default,
5927
- override with -t/--tenant-id. Mutating commands (discard, rollback) prompt
6265
+ override with -t/--tenant-id. Mutating commands (discard, restore) prompt
5928
6266
  for confirmation unless --yes.
5929
6267
 
5930
6268
  Full reference: ${DOCS_URL}
@@ -6055,7 +6393,7 @@ program.command("whoami").description("Show the resolved instance, tenant, crede
6055
6393
  });
6056
6394
  program.command("schema").description("Emit the bundled RevTurbineConfig JSON schema for an agent to author against.").option("--json", "Accepted for symmetry; output is always JSON").action(() => {
6057
6395
  try {
6058
- const jsonSchema = z27.toJSONSchema(RevTurbineConfigSchema, { unrepresentable: "any" });
6396
+ const jsonSchema = z29.toJSONSchema(RevTurbineConfigSchema, { unrepresentable: "any" });
6059
6397
  emit({ schema_version: SCHEMA_VERSION, schema: jsonSchema }, true);
6060
6398
  } catch (err) {
6061
6399
  fail(EXIT.UNEXPECTED, `could not render the bundled schema (${SCHEMA_VERSION}): ${err.message}`);
@@ -6112,9 +6450,23 @@ program.command("validate").description("Validate a Config File offline (schema)
6112
6450
  throw new SelectorError("STATE_REQUIRED \u2014 validate needs a version: <file> (offline) or --draft (server catalog).");
6113
6451
  }
6114
6452
  if (!opts.draft) {
6115
- const failures = files.filter((file) => verifyConfig(file) === null).length;
6116
- if (files.length > 1) diag(`${files.length - failures}/${files.length} passed.`);
6117
- process.exit(failures > 0 ? EXIT.VALIDATION : 0);
6453
+ let blockedFiles = 0;
6454
+ for (const file of files) {
6455
+ const raw = loadConfig(file);
6456
+ const parsed = schema.safeParse(raw);
6457
+ const findings = evaluate(parsed.success ? parsed.data : raw, {
6458
+ structuralErrors: parsed.success ? void 0 : parsed.error
6459
+ });
6460
+ diag(`Validation for ${file} (offline, schema ${SCHEMA_VERSION}):`);
6461
+ process.stdout.write(`${formatFindings(findings)}
6462
+ `);
6463
+ if (hasBlockingFindings(findings)) blockedFiles += 1;
6464
+ }
6465
+ if (files.length > 1) diag(`${files.length - blockedFiles}/${files.length} passed.`);
6466
+ if (blockedFiles > 0) {
6467
+ fail(EXIT.VALIDATION, `${blockedFiles} file(s) have blocking findings.`);
6468
+ }
6469
+ process.exit(0);
6118
6470
  }
6119
6471
  const conn = connect(opts.url, opts.tenantId);
6120
6472
  const playbookVersionId = await requireOpenDraft(conn);
@@ -6172,7 +6524,7 @@ program.command("upload").description("Stage a Config File as the open draft (PO
6172
6524
  if (playbookVersionId) diagRaw(` playbook_version_id: ${playbookVersionId}`);
6173
6525
  diag("Launch it with `revturbine launch --draft`, or from the UI (Drafts & Releases).");
6174
6526
  });
6175
- program.command("launch").description("Take a config live as a new Release: validate (launch gate), then submit \u2192 approve \u2192 deploy. Synchronous.").argument("[file]", "Config File to upload and launch directly").option("--draft", "Launch the already-open draft").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--yes", "Accepted for parity with discard/rollback; launch has no confirmation prompt").action(async (file, opts) => {
6527
+ program.command("launch").description("Take a config live as a new Release: validate (launch gate), then submit \u2192 approve \u2192 deploy. Synchronous.").argument("[file]", "Config File to upload and launch directly").option("--draft", "Launch the already-open draft").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--yes", "Accepted for parity with discard/restore; launch has no confirmation prompt").action(async (file, opts) => {
6176
6528
  const [sel] = requireSelectors(opts, file ? [file] : [], { count: 1, allowed: ["file", "draft"], command: "launch" });
6177
6529
  const conn = connect(opts.url, opts.tenantId);
6178
6530
  let playbookVersionId;
@@ -6201,17 +6553,22 @@ program.command("discard").description("Discard (archive) the open draft so a fr
6201
6553
  if (!res.ok) httpFail(conn, "discard", res.status, json);
6202
6554
  diag(`\u2713 Discarded draft ${playbookVersionId}.`);
6203
6555
  });
6204
- program.command("rollback").description("Roll back a deployed playbook version (creates a reverting draft). Becomes `restore <release>` once server-side entry copying lands (plan 131 TASK-6).").argument("<playbook-version-id>", "The deployed playbook_version_id to revert").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--yes", "Skip the confirmation prompt").action(async (playbookVersionId, opts) => {
6556
+ program.command("restore").description("Stage a draft that restores a past release (from its frozen snapshot); `--launch` takes it live. Halts if a draft is already open.").argument("<playbook-version-id>", "The deployed playbook version to restore (see `history`)").option("--launch", "Launch the restoring draft immediately (gate + submit \u2192 approve \u2192 deploy)").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--yes", "Skip the confirmation prompt").action(async (playbookVersionId, opts) => {
6205
6557
  const conn = connect(opts.url, opts.tenantId);
6206
6558
  await confirmOrExit(
6207
- `Roll back playbook version ${playbookVersionId} on ${conn.url}? This changes the LIVE configuration.`,
6559
+ `Restore playbook version ${playbookVersionId} on ${conn.url}?${opts.launch ? " --launch will take it LIVE." : " (stages a draft; launch separately)"}`,
6208
6560
  !!opts.yes
6209
6561
  );
6210
6562
  const { res, json } = await postJson(conn, `/api/playbook-versions/${playbookVersionId}/rollback`, {});
6211
- if (!res.ok) httpFail(conn, "rollback", res.status, json);
6212
- diag(`\u2713 Rollback requested for playbook version ${playbookVersionId}.`);
6563
+ if (!res.ok) httpFail(conn, "restore", res.status, json);
6213
6564
  const reverting = json?.item?.id ?? json?.playbook_version_id;
6214
- if (reverting) diagRaw(` reverting draft: ${reverting}`);
6565
+ diag(`\u2713 Staged a restoring draft from ${playbookVersionId}${reverting ? ` (${reverting})` : ""}.`);
6566
+ if (!opts.launch) {
6567
+ diag("Launch it with `revturbine launch --draft`, or discard it with `revturbine discard --yes`.");
6568
+ return;
6569
+ }
6570
+ if (!reverting) fail(EXIT.SERVER, "No reverting draft id returned \u2014 cannot launch.");
6571
+ await launchDraft(conn, reverting);
6215
6572
  });
6216
6573
  program.command("status").description("The current live Release and the open draft, side by side.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--json", "Machine-readable output").action(async (opts) => {
6217
6574
  const conn = connect(opts.url, opts.tenantId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "revturbine — verify RevTurbine ExportedConfig files and ship them to a RevTurbine instance through the Change Set lifecycle.",
5
5
  "license": "MIT",
6
6
  "repository": {