@revturbine/cli 0.16.3 → 0.17.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +113 -37
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -69,7 +69,7 @@ Commands that read a config name the version explicitly — there is no default:
69
69
  | `whoami` | The resolved instance, tenant, credentials source, and whether the stored token works. |
70
70
  | `schema` | Emit the bundled `RevTurbineConfig` JSON schema (for agents to author against). |
71
71
  | `docs` | Print the canonical documentation URL. |
72
- | `download` | Fetch a config version (`--live` / `--draft` / `--release <id>`); `--save`, `--format flatbuffer`. |
72
+ | `download` | Fetch a config version (`--live` / `--draft` / `--release <id>`); `--save`. |
73
73
  | `validate` | Offline schema validation of a `<file>`, or the full server catalog against the open draft (`--draft`). |
74
74
  | `diff` | Compare any two versions (dry-run, no writes). A file vs `--draft`/`--live`/`--release` previews the launch — the server side is the base, so `+`/`-` read as created/pruned on launch. |
75
75
  | `show <kind>` | Summary tables: `plans` · `entitlements` · `segments` · `placements` · `trials` for any version. |
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { createRequire } from "module";
7
7
  import { fileURLToPath } from "url";
8
8
  import { createInterface } from "readline/promises";
9
9
  import { spawn as spawn2, spawnSync } from "child_process";
10
- import { Command, CommanderError, Option } from "commander";
10
+ import { Command, CommanderError } from "commander";
11
11
  import { z as z29 } from "zod";
12
12
 
13
13
  // src/schema/exported-config.snapshot.mjs
@@ -1869,7 +1869,14 @@ var ClientContextCapabilitiesSchema = z9.object({
1869
1869
  );
1870
1870
  var ClientContextPlanSchema = z9.object({
1871
1871
  /** The customer's current plan handle (`plans.unique_handle`). */
1872
- handle: z9.string().min(1).optional().meta({ ...Unrestricted6, ...ClientSafe })
1872
+ handle: z9.string().min(1).optional().meta({ ...Unrestricted6, ...ClientSafe }),
1873
+ /**
1874
+ * The plan's display name, resolved server-side from the plan record so
1875
+ * client UI can render it without a Playbook lookup (plan 179 TASK-1 —
1876
+ * Q-2 ruling: `{ handle, name }`). Plan names are already client-visible
1877
+ * in every Playbook; no new exposure.
1878
+ */
1879
+ name: z9.string().min(1).optional().meta({ ...Unrestricted6, ...ClientSafe })
1873
1880
  }).meta(
1874
1881
  { id: "ClientContextPlan", "x-revturbine-schema-persistence": Transient6, "x-revturbine-schema-exposure": Internal5 }
1875
1882
  );
@@ -5940,6 +5947,18 @@ var CATALOG = {
5940
5947
  message: "These rules for the same entitlement target the same plan and segment \u2014 only one will apply.",
5941
5948
  specRef: "config-validation.md \xA75.3 (provisional \u2014 plan 73 Q-1)"
5942
5949
  },
5950
+ // limit rule with unset enforcement. Promoted from §5.8 (plan 179 Q-6,
5951
+ // Kent 2026-08-12): unset enforcement silently means "hard-block at the
5952
+ // cap" (evaluator default since plan 34) — an author who wanted degrade or
5953
+ // allow_overage gets a hard stop without being told. Explicitness warn on
5954
+ // the corrected premise (the old "never blocks" mechanism was the SDK
5955
+ // ignoring allowed:false, fixed in sdk 0.2.75).
5956
+ "VAL-PLN-07": {
5957
+ id: "VAL-PLN-07",
5958
+ severity: "warning",
5959
+ message: "This limit rule sets no enforcement \u2014 at the cap it blocks by default. Set enforcement explicitly (hard_block, soft_block, degrade, allow_overage) if that isn't the intent.",
5960
+ specRef: "config-validation.md \xA75.8 (promoted \u2014 plan 179 Q-6)"
5961
+ },
5943
5962
  // multiple public variations. Origin: `*_variation.multiple_public`.
5944
5963
  "VAL-PLN-06": {
5945
5964
  id: "VAL-PLN-06",
@@ -5976,11 +5995,33 @@ function runSemanticRules(graph) {
5976
5995
  return [
5977
5996
  ...checkPlansHaveStripePrice(graph),
5978
5997
  ...checkRuleOverlaps(graph),
5998
+ ...checkLimitRuleEnforcement(graph),
5979
5999
  ...checkPublicVariationCollisions(graph),
5980
6000
  ...checkPayloadCtaOverflow(graph),
5981
6001
  ...checkTrialRuleWidestAudience(graph)
5982
6002
  ];
5983
6003
  }
6004
+ function checkLimitRuleEnforcement(graph) {
6005
+ const findings = [];
6006
+ const CAP_FIELDS = ["limit_value", "allowance_value", "included_count"];
6007
+ for (const rule of graph.entitlement_rules ?? []) {
6008
+ if (rule.enforcement !== void 0 && rule.enforcement !== null) continue;
6009
+ const cappedField = CAP_FIELDS.find((f) => typeof rule[f] === "number" && Number.isFinite(rule[f]));
6010
+ if (!cappedField) continue;
6011
+ const id = String(rule.handle ?? rule.id ?? "");
6012
+ const name = String(rule.name ?? id);
6013
+ findings.push(
6014
+ finding(
6015
+ "VAL-PLN-07",
6016
+ { object_type: "entitlement_rule", object_id: id, field: "enforcement", studio: "plans-entitlements" },
6017
+ {
6018
+ message: `Limit rule '${name}' sets no enforcement \u2014 at the cap it blocks by default. Set enforcement explicitly (hard_block, soft_block, degrade, allow_overage) if that isn't the intent.`
6019
+ }
6020
+ )
6021
+ );
6022
+ }
6023
+ return findings;
6024
+ }
5984
6025
  function surfaceCtas(surface) {
5985
6026
  if (!surface || typeof surface !== "object") return 0;
5986
6027
  const ctas = surface.ctas;
@@ -8923,7 +8964,7 @@ function repairDeprecatedFields(config, schema = PlaybookObjectSchema2) {
8923
8964
  }
8924
8965
 
8925
8966
  // src/schema/version.ts
8926
- var SCHEMA_VERSION = "0.1.167";
8967
+ var SCHEMA_VERSION = "0.1.168";
8927
8968
 
8928
8969
  // src/lib/credentials.ts
8929
8970
  import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
@@ -9364,6 +9405,34 @@ async function resolveActiveDraft(baseUrl, headers, fetchImpl = fetch) {
9364
9405
  return { ok: res.ok, status: res.status, draft: res.ok ? json.active ?? null : null };
9365
9406
  }
9366
9407
 
9408
+ // src/lib/evaluate-local.ts
9409
+ import { initRevTurbine } from "@revturbine/sdk/headless";
9410
+ async function evaluateLocal(config, input) {
9411
+ const user = { id: input.userId };
9412
+ if (input.planHandle) user.plan_handle = input.planHandle;
9413
+ if (input.traits && Object.keys(input.traits).length > 0) user.custom = input.traits;
9414
+ const session = await initRevTurbine({
9415
+ user,
9416
+ localRuntime: { playbook: config }
9417
+ });
9418
+ const entitlements = {};
9419
+ for (const handle of input.entitlementHandles ?? []) {
9420
+ entitlements[handle] = await session.checkEntitlement(handle);
9421
+ }
9422
+ const decisions = [];
9423
+ for (const placementId of input.placementIds ?? []) {
9424
+ const controller = session.placement({ placement: { name: placementId } });
9425
+ await controller.load();
9426
+ decisions.push(controller.state.decision);
9427
+ }
9428
+ const placement = input.slot && (input.slot.slotId || input.slot.surfaceType) ? await session.getPlacement({
9429
+ ...input.slot.slotId ? { slotId: input.slot.slotId } : {},
9430
+ ...input.slot.surfaceType ? { surfaceType: input.slot.surfaceType } : {},
9431
+ ...input.planHandle ? { planHandle: input.planHandle } : {}
9432
+ }) : null;
9433
+ return { decisions, entitlements, placement };
9434
+ }
9435
+
9367
9436
  // src/lib/ingest-keys.ts
9368
9437
  async function readJson(res) {
9369
9438
  return await res.json().catch(() => ({}));
@@ -10611,27 +10680,11 @@ program.command("docs").description("Print the canonical documentation URL (and
10611
10680
  }
10612
10681
  }
10613
10682
  });
10614
- program.command("download").description("Fetch a config version from the server. Requires --live, --draft, or --release <id>.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--draft", "The tenant's open draft (rendered on demand)").option("--live", "The current live Release").option("--release <id>", "A specific playbook version / Release").addOption(new Option("-f, --format <format>", "Representation").choices(["json", "flatbuffer"]).default("json")).option("--save <file>", "Write to <file> instead of stdout").action(
10683
+ program.command("download").description("Fetch a config version from the server. Requires --live, --draft, or --release <id>.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--draft", "The tenant's open draft (rendered on demand)").option("--live", "The current live Release").option("--release <id>", "A specific playbook version / Release").option("--save <file>", "Write to <file> instead of stdout").action(
10615
10684
  async (opts) => {
10616
10685
  const [sel] = requireSelectors(opts, [], { count: 1, allowed: ["draft", "live", "release"], command: "download" });
10617
10686
  const conn = connect(opts.url, opts.tenantId);
10618
10687
  const playbookVersionId = sel.kind === "release" ? sel.id : sel.kind === "draft" ? await requireOpenDraft(conn) : void 0;
10619
- if (opts.format === "flatbuffer") {
10620
- const qs = playbookVersionId ? `?playbookVersionId=${encodeURIComponent(playbookVersionId)}` : "";
10621
- const res = await request(conn, `/api/config/bundle${qs}`);
10622
- if (!res.ok) httpFail(conn, "bundle download", res.status);
10623
- const bytes = Buffer.from(await res.arrayBuffer());
10624
- if (opts.save) {
10625
- const out2 = path2.resolve(opts.save);
10626
- mkdirSync2(path2.dirname(out2), { recursive: true });
10627
- writeFileSync2(out2, bytes);
10628
- diag(`\u2713 Wrote ${bytes.length} bytes \u2192 ${out2}`);
10629
- } else {
10630
- process.stdout.write(`${bytes.toString("base64")}
10631
- `);
10632
- }
10633
- return;
10634
- }
10635
10688
  const config = await downloadConfig(conn, playbookVersionId);
10636
10689
  diag(`Downloaded: ${describePlaybookHeader(readPlaybookHeader(config))}`);
10637
10690
  const out = `${JSON.stringify(config, null, 2)}
@@ -10841,7 +10894,9 @@ program.command("preview").description("The open draft's Runtime Impact summary
10841
10894
  ].join("\n");
10842
10895
  emit(data, Boolean(opts.json), text);
10843
10896
  });
10844
- program.command("evaluate").description("Run placement/entitlement decisions for a user context against a config version. Requires --live, --draft, or --release <id>.").option("--live", "Evaluate the live configuration").option("--draft", "Evaluate the tenant's open draft (clean-room: no suppression/cap history)").option("--release <id>", "Evaluate a past release (from its frozen snapshot)").option("--entitlement <handle>", "Check one entitlement (the checkEntitlement result)").option("--slot <id>", "Evaluate one surface slot (the getPlacement decision for that slot)").option("--surface-type <type>", "Disambiguate a slot that can render more than one surface (with --slot), or resolve by surface type alone").option("--plan-handle <handle>", "Evaluate as if the user were on this plan (overrides the ctx file)").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).requiredOption("--user <file>", "JSON file: { user_id, customer_id?, plan_handle?, traits?, now_iso? }").option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(
10897
+ program.command("evaluate").description(
10898
+ "Run placement/entitlement decisions for a user context against a config version \u2014 evaluated LOCALLY: the CLI fetches the version's Playbook and runs the SDK engine in-process (plan 192; clean-room, no suppression/cap history). Requires --live, --draft, or --release <id>."
10899
+ ).option("--live", "Evaluate the live (deployed) configuration").option("--draft", "Evaluate the tenant's open draft").option("--release <id>", "Evaluate a past release (from its frozen snapshot)").option("--entitlement <handle>", "Check one entitlement (the checkEntitlement result)").option("--slot <id>", "Evaluate one surface slot (the getPlacement decision for that slot)").option("--surface-type <type>", "Disambiguate a slot that can render more than one surface (with --slot), or resolve by surface type alone").option("--plan-handle <handle>", "Evaluate as if the user were on this plan (overrides the ctx file)").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).requiredOption("--user <file>", "JSON file: { user_id, customer_id?, plan_handle?, traits?, now_iso? }").option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(
10845
10900
  async (opts) => {
10846
10901
  const [sel] = requireSelectors(opts, [], { count: 1, allowed: ["draft", "live", "release"], command: "evaluate" });
10847
10902
  const conn = connect(opts.url, opts.tenantId);
@@ -10852,27 +10907,48 @@ program.command("evaluate").description("Run placement/entitlement decisions for
10852
10907
  } catch (err) {
10853
10908
  fail(EXIT.VALIDATION, `invalid JSON in ${opts.user}: ${err.message}`);
10854
10909
  }
10855
- const body = { ...ctx };
10856
- if (opts.planHandle) body.plan_handle = opts.planHandle;
10857
10910
  const wantsSlot = Boolean(opts.slot || opts.surfaceType);
10858
10911
  if (opts.entitlement && wantsSlot) {
10859
10912
  fail(EXIT.USAGE, "pass exactly one of --entitlement or --slot/--surface-type (or neither for the ctx file lists).");
10860
10913
  }
10861
- if (opts.entitlement) {
10862
- body.entitlement_handles = [opts.entitlement];
10863
- body.placement_ids = [];
10914
+ const userId = typeof ctx.user_id === "string" && ctx.user_id.length > 0 ? ctx.user_id : void 0;
10915
+ if (!userId) fail(EXIT.VALIDATION, `user file must carry a non-empty string "user_id" (${opts.user})`);
10916
+ if (typeof ctx.now_iso === "string") {
10917
+ diag("WARNING: now_iso is ignored \u2014 local evaluation uses the real clock.");
10864
10918
  }
10865
- if (wantsSlot) {
10866
- if (opts.slot) body.slot_id = opts.slot;
10867
- if (opts.surfaceType) body.surface_type = opts.surfaceType;
10868
- body.placement_ids = [];
10869
- body.entitlement_handles = [];
10919
+ if (typeof ctx.customer_id === "string") {
10920
+ diag(
10921
+ "WARNING: customer_id is ignored \u2014 supply plan_handle in the ctx file (or --plan-handle). The server-side customer\u2192plan lookup was retired with the decision endpoint (plan 192)."
10922
+ );
10870
10923
  }
10871
- if (sel.kind === "release") body.playbook_version_id = sel.id;
10872
- if (sel.kind === "draft") body.playbook_version_id = await requireOpenDraft(conn);
10873
- const { res, json } = await postJson(conn, "/api/sdk/evaluate", body);
10874
- if (!res.ok) httpFail(conn, "evaluate", res.status, json);
10875
- emit(json, true);
10924
+ let playbookVersionId;
10925
+ if (sel.kind === "release") playbookVersionId = sel.id;
10926
+ if (sel.kind === "draft") playbookVersionId = await requireOpenDraft(conn);
10927
+ const config = await downloadConfig(conn, playbookVersionId);
10928
+ const planHandle = opts.planHandle ?? (typeof ctx.plan_handle === "string" ? ctx.plan_handle : void 0);
10929
+ const bulk = !opts.entitlement && !wantsSlot;
10930
+ const asStringArray2 = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
10931
+ const traits = typeof ctx.traits === "object" && ctx.traits !== null && !Array.isArray(ctx.traits) ? ctx.traits : void 0;
10932
+ const result = await evaluateLocal(config, {
10933
+ userId,
10934
+ planHandle,
10935
+ traits,
10936
+ entitlementHandles: opts.entitlement ? [opts.entitlement] : bulk ? asStringArray2(ctx.entitlement_handles) : [],
10937
+ placementIds: bulk ? asStringArray2(ctx.placement_ids) : [],
10938
+ slot: wantsSlot ? { slotId: opts.slot, surfaceType: opts.surfaceType } : void 0
10939
+ });
10940
+ emit(
10941
+ {
10942
+ tenant_id: conn.tenantId,
10943
+ user_id: userId,
10944
+ ...playbookVersionId ? { playbook_version_id: playbookVersionId } : {},
10945
+ evaluated_at: (/* @__PURE__ */ new Date()).toISOString(),
10946
+ // Provenance: decided in-process by the SDK engine, not by a server.
10947
+ evaluation: "local",
10948
+ ...result
10949
+ },
10950
+ true
10951
+ );
10876
10952
  }
10877
10953
  );
10878
10954
  var generateCmd = program.command("generate").description("Code generation from a config version (see: generate types).");
@@ -10950,7 +11026,7 @@ var COMMAND_EXAMPLES = {
10950
11026
  "Examples:",
10951
11027
  " revturbine download --live --save ./revturbine.playbook.json The live config \u2192 file",
10952
11028
  " revturbine download --draft The open draft (rendered on demand) \u2192 stdout",
10953
- " revturbine download --release cs_1a2b3c --format flatbuffer --save ./bundle.fb"
11029
+ " revturbine download --release cs_1a2b3c --save ./release.playbook.json"
10954
11030
  ].join("\n"),
10955
11031
  validate: [
10956
11032
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "description": "revturbine — validate RevTurbine Playbooks and ship them to a RevTurbine instance through the playbook-version lifecycle (draft → Release).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,6 @@
23
23
  "scripts": {
24
24
  "build": "tsup",
25
25
  "generate:schema": "node scripts/generate-schema-snapshot.mjs",
26
- "revt:sync": "node scripts/sync-revt-deps.mjs",
27
26
  "typecheck": "tsc --noEmit",
28
27
  "test": "vitest run",
29
28
  "lint": "eslint .",
@@ -31,6 +30,7 @@
31
30
  "verify": "npm run typecheck && npm run lint && npm run check:schema && npm run test"
32
31
  },
33
32
  "dependencies": {
33
+ "@revturbine/sdk": "^0.2.88",
34
34
  "commander": "^15.0.0",
35
35
  "zod": "4.4.3"
36
36
  },