@revturbine/cli 0.5.0 → 0.5.2

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 (2) hide show
  1. package/dist/cli.js +380 -8
  2. package/package.json +1 -1
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);
@@ -5686,6 +6024,16 @@ function requireSelectors(opts, positionalFiles, {
5686
6024
  return found;
5687
6025
  }
5688
6026
 
6027
+ // src/lib/version-trail.ts
6028
+ function serverSchemaIsNewer(config, bundledVersion) {
6029
+ const server = config?.schema_version;
6030
+ if (typeof server !== "string" || !server) return null;
6031
+ const parse = (v) => v.split(".").map((n) => Number.parseInt(n, 10) || 0);
6032
+ const [a, b] = [parse(server), parse(bundledVersion)];
6033
+ const newer = a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : (a[2] ?? 0) > (b[2] ?? 0);
6034
+ return newer ? server : null;
6035
+ }
6036
+
5689
6037
  // src/cli.ts
5690
6038
  var DOCS_URL = "https://github.com/revt-eng/revturbine-cli#readme";
5691
6039
  var DEFAULT_URL = "https://revturbine.com/app";
@@ -5766,11 +6114,21 @@ async function getJson(conn, pathname) {
5766
6114
  const json = await res.json().catch(() => ({}));
5767
6115
  return { res, json };
5768
6116
  }
6117
+ function warnIfSchemaBehind(config) {
6118
+ const server = serverSchemaIsNewer(config, SCHEMA_VERSION);
6119
+ if (server) {
6120
+ diag(
6121
+ `WARNING: the server's schema (${server}) is newer than this CLI's bundled snapshot (${SCHEMA_VERSION}) \u2014 offline validation may be missing rules. Update: npm i -g @revturbine/cli`
6122
+ );
6123
+ }
6124
+ }
5769
6125
  async function downloadConfig(conn, playbookVersionId) {
5770
6126
  const qs = playbookVersionId ? `?playbookVersionId=${encodeURIComponent(playbookVersionId)}` : "";
5771
6127
  const res = await request(conn, `/api/config/export${qs}`);
5772
6128
  if (!res.ok) httpFail(conn, "download", res.status);
5773
- return res.json().catch(() => ({}));
6129
+ const config = await res.json().catch(() => ({}));
6130
+ warnIfSchemaBehind(config);
6131
+ return config;
5774
6132
  }
5775
6133
  async function requireOpenDraft(conn) {
5776
6134
  const { ok, status, draft } = await resolveActiveDraft(conn.url, conn.headers);
@@ -6055,7 +6413,7 @@ program.command("whoami").description("Show the resolved instance, tenant, crede
6055
6413
  });
6056
6414
  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
6415
  try {
6058
- const jsonSchema = z27.toJSONSchema(RevTurbineConfigSchema, { unrepresentable: "any" });
6416
+ const jsonSchema = z29.toJSONSchema(RevTurbineConfigSchema, { unrepresentable: "any" });
6059
6417
  emit({ schema_version: SCHEMA_VERSION, schema: jsonSchema }, true);
6060
6418
  } catch (err) {
6061
6419
  fail(EXIT.UNEXPECTED, `could not render the bundled schema (${SCHEMA_VERSION}): ${err.message}`);
@@ -6112,9 +6470,23 @@ program.command("validate").description("Validate a Config File offline (schema)
6112
6470
  throw new SelectorError("STATE_REQUIRED \u2014 validate needs a version: <file> (offline) or --draft (server catalog).");
6113
6471
  }
6114
6472
  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);
6473
+ let blockedFiles = 0;
6474
+ for (const file of files) {
6475
+ const raw = loadConfig(file);
6476
+ const parsed = schema.safeParse(raw);
6477
+ const findings = evaluate(parsed.success ? parsed.data : raw, {
6478
+ structuralErrors: parsed.success ? void 0 : parsed.error
6479
+ });
6480
+ diag(`Validation for ${file} (offline, schema ${SCHEMA_VERSION}):`);
6481
+ process.stdout.write(`${formatFindings(findings)}
6482
+ `);
6483
+ if (hasBlockingFindings(findings)) blockedFiles += 1;
6484
+ }
6485
+ if (files.length > 1) diag(`${files.length - blockedFiles}/${files.length} passed.`);
6486
+ if (blockedFiles > 0) {
6487
+ fail(EXIT.VALIDATION, `${blockedFiles} file(s) have blocking findings.`);
6488
+ }
6489
+ process.exit(0);
6118
6490
  }
6119
6491
  const conn = connect(opts.url, opts.tenantId);
6120
6492
  const playbookVersionId = await requireOpenDraft(conn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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": {