@revturbine/cli 0.3.0 → 0.4.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 +54 -22
  2. package/dist/cli.js +530 -313
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # revturbine-cli (`revturbine`)
2
2
 
3
- Verify [RevTurbine](https://revturbine.com) **ExportedConfig** files and ship
4
- them to a RevTurbine instance through the playbook-version lifecycle from the
5
- terminal, the same operations the in-app Pre-Sales Agent performs.
3
+ Validate [RevTurbine](https://revturbine.com) **Config Files** and ship them to
4
+ a RevTurbine instance through the playbook-version lifecycle (draft Release) —
5
+ from the terminal, the same operations the in-app studios perform.
6
6
 
7
7
  `revturbine` is the command-line counterpart to the RevTurbine control plane. It
8
8
  schema-validates a config offline, authenticates to an instance via the browser
9
- (RFC 8628 device flow), and stages / deploys configs as playbook versions so
9
+ (RFC 8628 device flow), and stages / launches configs as playbook versions so
10
10
  every change is reviewable and rollback-able.
11
11
 
12
12
  ## Install
@@ -25,38 +25,70 @@ Requires Node ≥ 20.
25
25
  ## Quick start
26
26
 
27
27
  ```bash
28
- revturbine signup # create an account (email + password + emailed code)
29
- revturbine verify ./export-config.json # schema-validate locally (no network)
30
- revturbine login # authorize this machine (device flow)
31
- revturbine diff ./export-config.json # dry-run: live config vs local, no writes
32
- revturbine upload ./export-config.json # stage as a draft playbook version
33
- revturbine deploy <playbook-version-id> # submit approve → deploy (go live)
28
+ revturbine signup # create an account (email + password + emailed code)
29
+ revturbine login # authorize this machine (device flow)
30
+ revturbine download --live --save ./config.json
31
+ # …edit ./config.json
32
+ revturbine validate ./config.json # schema-validate locally (no network)
33
+ revturbine diff ./config.json --live # what would change (no writes)
34
+ revturbine launch ./config.json # upload, gate, and go live
34
35
  ```
35
36
 
36
37
  `revturbine <command> --help` documents every flag.
37
38
 
39
+ ## Version selectors
40
+
41
+ Commands that read a config name the version explicitly — there is no default:
42
+
43
+ | Selector | Meaning |
44
+ |---|---|
45
+ | `<file>` / `--file <path>` | a local Config File |
46
+ | `--draft` | the tenant's single open draft (resolved automatically) |
47
+ | `--live` | the current live Release |
48
+ | `--release <id>` | a specific playbook version / Release |
49
+
38
50
  ## Commands
39
51
 
40
52
  | Command | What it does |
41
53
  |---|---|
42
- | `signup` | Create an account headlessly: email + password, then an emailed one-time code to verify, then a token is stored. A business-email domain already claimed by a workspace exits with "an admin must invite you" (no token). |
54
+ | `signup` | Create an account headlessly: email + password, then an emailed one-time code to verify, then a token is stored. |
43
55
  | `login` / `logout` | Device-flow auth; tokens stored at `~/.revturbine/credentials.json` (mode 0600). |
44
- | `verify` | Schema-validate a config offline against the bundled schema. |
45
- | `diff` | Non-destructive: download the live config, diff against local, dry-run the import. |
46
- | `upload` | Stage a config as a draft playbook version (`--deploy` to also activate). |
47
- | `deploy` | Activate a staged draft: submit approve deploy. |
48
- | `validate` | Run config-validation against a staged draft. |
49
- | `export` | Download the live config (stdout, or `--save`). |
50
- | `status` | Show the active draft and recent releases. |
51
- | `discard` / `rollback` | Archive an open draft / revert a deployed playbook version. |
56
+ | `whoami` | The resolved instance, tenant, credentials source, and whether the stored token works. |
57
+ | `schema` | Emit the bundled `RevTurbineConfig` JSON schema (for agents to author against). |
58
+ | `docs` | Print the canonical documentation URL. |
59
+ | `download` | Fetch a config version (`--live` / `--draft` / `--release <id>`); `--save`, `--format flatbuffer`. |
60
+ | `validate` | Offline schema validation of a `<file>`, or the full server catalog against the open draft (`--draft`). |
61
+ | `diff` | Compare any two versions, first → second (dry-run, no writes). |
62
+ | `show <kind>` | Summary tables: `plans` · `entitlements` · `segments` · `placements` · `trials` for any version. |
63
+ | `upload` | Stage a Config File as the open draft. |
64
+ | `launch` | Take a config live: validate (launch gate) → submit → approve → deploy. `launch <file>` or `launch --draft`. |
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). |
67
+ | `status` | The live Release and the open draft, side by side. |
68
+ | `history` | The Release Version Log, newest first. |
69
+ | `preview` | The open draft's staged changes. |
52
70
  | `evaluate` | Run the live config's placement/entitlement decisions for a user context. |
53
- | `preview` | Runtime-impact preview of a draft playbook version. |
54
- | `promote` | Promote a compiled config from one environment to another. |
71
+
72
+ `--json` on read commands emits machine-readable output. Results go to stdout,
73
+ diagnostics to stderr.
74
+
75
+ ## Exit-code classes
76
+
77
+ ```
78
+ 0 success
79
+ 1 unexpected error (catch-all)
80
+ 2 bad usage — unknown command, flag, argument, or missing version selector
81
+ 3 authentication or permission denied
82
+ 4 validation blocked (schema failure, blocking findings, unknown id)
83
+ 5 conflict or stale state (e.g. a draft is already open)
84
+ 6 network or transient failure
85
+ 7 server error
86
+ ```
55
87
 
56
88
  ## Schema validation
57
89
 
58
90
  Validation is **mandatory** and runs fully offline against a vendored,
59
- version-stamped snapshot of RevTurbine's `ExportedConfigSchema`
91
+ version-stamped snapshot of RevTurbine's `RevTurbineConfigSchema`
60
92
  (`src/schema/`, regenerated from the canonical schema via
61
93
  `npm run generate:schema`). The CLI never uploads a config it could not
62
94
  validate. `revturbine --version` reports both the CLI version and the schema
package/dist/cli.js CHANGED
@@ -5,7 +5,9 @@ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as rea
5
5
  import path2 from "path";
6
6
  import { createRequire } from "module";
7
7
  import { createInterface } from "readline/promises";
8
- import { Command, Option } from "commander";
8
+ import { spawn as spawn2 } from "child_process";
9
+ import { Command, CommanderError, Option } from "commander";
10
+ import { z as z27 } from "zod";
9
11
 
10
12
  // src/schema/exported-config.snapshot.mjs
11
13
  import { z as z2 } from "zod";
@@ -5222,7 +5224,7 @@ var AuthSsoProviderSchema = IdField.extend({
5222
5224
  }).meta({ id: "AuthSsoProvider", "x-revturbine-schema-persistence": Persisted23, "x-revturbine-schema-exposure": Internal19 });
5223
5225
 
5224
5226
  // src/schema/version.ts
5225
- var SCHEMA_VERSION = "0.1.106";
5227
+ var SCHEMA_VERSION = "0.1.107";
5226
5228
 
5227
5229
  // src/lib/credentials.ts
5228
5230
  import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
@@ -5553,7 +5555,7 @@ function formatDiff(diff) {
5553
5555
  }
5554
5556
 
5555
5557
  // src/lib/config-validate.ts
5556
- var BLOCKING_SEVERITIES = /* @__PURE__ */ new Set(["error_draft", "error_publish"]);
5558
+ var BLOCKING_SEVERITIES = /* @__PURE__ */ new Set(["error_draft", "error_launch"]);
5557
5559
  function isBlockingFinding(finding) {
5558
5560
  return BLOCKING_SEVERITIES.has(finding.severity);
5559
5561
  }
@@ -5579,33 +5581,134 @@ async function fetchValidation(baseUrl, playbookVersionId, headers, fetchImpl =
5579
5581
  };
5580
5582
  }
5581
5583
 
5582
- // src/cli.ts
5584
+ // src/lib/drafts.ts
5585
+ async function resolveActiveDraft(baseUrl, headers, fetchImpl = fetch) {
5586
+ const res = await fetchImpl(`${baseUrl}/api/optimization/drafts`, { headers });
5587
+ const json = await res.json().catch(() => ({}));
5588
+ return { ok: res.ok, status: res.status, draft: res.ok ? json.active ?? null : null };
5589
+ }
5590
+
5591
+ // src/lib/output.ts
5592
+ var EXIT = {
5593
+ OK: 0,
5594
+ /** Unexpected error (catch-all). */
5595
+ UNEXPECTED: 1,
5596
+ /** Bad usage — unknown command, flag, argument, or missing version selector. */
5597
+ USAGE: 2,
5598
+ /** Authentication or permission denied. */
5599
+ AUTH: 3,
5600
+ /** Validation blocked (schema failure, blocking findings, rejected config, unknown id). */
5601
+ VALIDATION: 4,
5602
+ /** Conflict or stale state (open draft exists, base moved). */
5603
+ CONFLICT: 5,
5604
+ /** Network or transient failure. */
5605
+ NETWORK: 6,
5606
+ /** Server error. */
5607
+ SERVER: 7
5608
+ };
5609
+ function classFromStatus(status) {
5610
+ if (status === 401 || status === 403) return EXIT.AUTH;
5611
+ if (status === 409) return EXIT.CONFLICT;
5612
+ if (status === 400 || status === 404 || status === 422) return EXIT.VALIDATION;
5613
+ if (status >= 500) return EXIT.SERVER;
5614
+ return EXIT.UNEXPECTED;
5615
+ }
5616
+ function isNetworkError(err) {
5617
+ return err instanceof TypeError || err instanceof Error && /fetch failed|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN/i.test(err.message);
5618
+ }
5583
5619
  var LOG = "[revturbine]";
5620
+ function diag(message) {
5621
+ console.error(`${LOG} ${message}`);
5622
+ }
5623
+ function diagRaw(message) {
5624
+ console.error(message);
5625
+ }
5626
+ function emit(data, json, humanText) {
5627
+ if (json) {
5628
+ process.stdout.write(`${JSON.stringify(data, null, 2)}
5629
+ `);
5630
+ } else {
5631
+ process.stdout.write(`${humanText ?? JSON.stringify(data, null, 2)}
5632
+ `);
5633
+ }
5634
+ }
5635
+ function fail(cls, message) {
5636
+ console.error(`${LOG} \u2717 ${message}`);
5637
+ process.exit(cls);
5638
+ }
5639
+
5640
+ // src/lib/selectors.ts
5641
+ var SelectorError = class extends Error {
5642
+ code = "STATE_REQUIRED";
5643
+ constructor(message) {
5644
+ super(message);
5645
+ }
5646
+ };
5647
+ function collectSelectors(opts, positionalFiles = []) {
5648
+ const found = positionalFiles.map((path3) => ({ kind: "file", path: path3 }));
5649
+ if (opts.file) found.push({ kind: "file", path: opts.file });
5650
+ if (opts.draft) found.push({ kind: "draft" });
5651
+ if (opts.live) found.push({ kind: "live" });
5652
+ if (opts.release) found.push({ kind: "release", id: opts.release });
5653
+ return found;
5654
+ }
5655
+ function describeSelector(sel) {
5656
+ switch (sel.kind) {
5657
+ case "file":
5658
+ return sel.path;
5659
+ case "draft":
5660
+ return "--draft";
5661
+ case "live":
5662
+ return "--live";
5663
+ case "release":
5664
+ return `--release ${sel.id}`;
5665
+ }
5666
+ }
5667
+ function requireSelectors(opts, positionalFiles, {
5668
+ count,
5669
+ allowed,
5670
+ command
5671
+ }) {
5672
+ const found = collectSelectors(opts, positionalFiles);
5673
+ const choices = allowed.map((k) => k === "file" ? "<file>" : k === "release" ? "--release <id>" : `--${k}`).join(" / ");
5674
+ const disallowed = found.find((s) => !allowed.includes(s.kind));
5675
+ if (disallowed) {
5676
+ throw new SelectorError(`${command} does not accept ${describeSelector(disallowed)} \u2014 use ${choices}.`);
5677
+ }
5678
+ if (found.length === 0) {
5679
+ throw new SelectorError(
5680
+ `STATE_REQUIRED \u2014 ${command} needs ${count === 1 ? "a version" : `${count} versions`}: ${choices}. There is no default.`
5681
+ );
5682
+ }
5683
+ if (found.length !== count) {
5684
+ throw new SelectorError(`${command} takes exactly ${count} version${count === 1 ? "" : "s"} (${choices}); got ${found.length}.`);
5685
+ }
5686
+ return found;
5687
+ }
5688
+
5689
+ // src/cli.ts
5690
+ var DOCS_URL = "https://github.com/revt-eng/revturbine-cli#readme";
5584
5691
  var DEFAULT_URL = "https://revturbine.com/app";
5585
5692
  var schema = RevTurbineConfigSchema;
5586
5693
  function loadConfig(file) {
5587
5694
  const resolved = path2.resolve(file);
5588
- if (!existsSync2(resolved)) {
5589
- console.error(`${LOG} Config not found: ${resolved}`);
5590
- process.exit(1);
5591
- }
5695
+ if (!existsSync2(resolved)) fail(EXIT.USAGE, `Config not found: ${resolved}`);
5592
5696
  try {
5593
5697
  return JSON.parse(readFileSync2(resolved, "utf8"));
5594
5698
  } catch (err) {
5595
- console.error(`${LOG} invalid JSON in ${resolved}: ${err.message}`);
5596
- process.exit(1);
5699
+ fail(EXIT.VALIDATION, `invalid JSON in ${resolved}: ${err.message}`);
5597
5700
  }
5598
5701
  }
5599
5702
  function verifyConfig(file) {
5600
5703
  const parsed = loadConfig(file);
5601
5704
  const result = schema.safeParse(parsed);
5602
5705
  if (result.success) {
5603
- console.log(`${LOG} \u2713 ${file}: schema validation passed`);
5706
+ diag(`\u2713 ${file}: schema validation passed`);
5604
5707
  return result.data;
5605
5708
  }
5606
- console.error(`${LOG} \u2717 ${file}: schema validation FAILED:`);
5709
+ diag(`\u2717 ${file}: schema validation FAILED:`);
5607
5710
  for (const issue of result.error.issues) {
5608
- console.error(` ${issue.path.join(".") || "<root>"}: ${issue.message}`);
5711
+ diagRaw(` ${issue.path.join(".") || "<root>"}: ${issue.message}`);
5609
5712
  }
5610
5713
  return null;
5611
5714
  }
@@ -5615,15 +5718,19 @@ function connect(rawUrl, explicitTenantId) {
5615
5718
  const cred = getCredential(url);
5616
5719
  const tenantId = explicitTenantId ?? cred?.tenant_id ?? "dev-tenant-001";
5617
5720
  const tenantSource = explicitTenantId ? "--tenant-id" : cred?.tenant_id ? "stored token" : "default";
5618
- console.log(`${LOG} Tenant ${tenantId} (${tenantSource}); credentials: ${dir} [${source}].`);
5721
+ diag(`Tenant ${tenantId} (${tenantSource}); credentials: ${dir} [${source}].`);
5619
5722
  if (source === "global") {
5620
- console.warn(
5621
- `${LOG} WARNING: using the global ${dir} - NOT worktree-scoped. If this session is for a specific customer, run from that customer's worktree (with its own .revturbine/) so a stale login can't target the wrong tenant.`
5723
+ diag(
5724
+ `WARNING: using the global ${dir} - NOT worktree-scoped. If this session is for a specific customer, run from that customer's worktree (with its own .revturbine/) so a stale login can't target the wrong tenant.`
5622
5725
  );
5623
5726
  }
5624
5727
  return {
5625
5728
  url,
5626
5729
  tenantId,
5730
+ tenantSource,
5731
+ credentialsDir: dir,
5732
+ credentialsSource: source,
5733
+ hasToken: Boolean(cred),
5627
5734
  headers: {
5628
5735
  "Content-Type": "application/json",
5629
5736
  "x-tenant-id": tenantId,
@@ -5633,129 +5740,216 @@ function connect(rawUrl, explicitTenantId) {
5633
5740
  }
5634
5741
  function authHint(url, status) {
5635
5742
  if (status === 401 || status === 403) {
5636
- console.error(
5637
- `
5638
- ${LOG} Authentication required for ${url}. Log in with:
5639
- revturbine login ${url}`
5640
- );
5743
+ diag(`Authentication required for ${url}. Log in with:
5744
+ revturbine login ${url}`);
5745
+ }
5746
+ }
5747
+ function httpFail(conn, what, status, detail) {
5748
+ authHint(conn.url, status);
5749
+ fail(classFromStatus(status), `${what} failed (${status})${detail ? `: ${JSON.stringify(detail)}` : ""}`);
5750
+ }
5751
+ async function request(conn, pathname, init) {
5752
+ try {
5753
+ return await fetch(`${conn.url}${pathname}`, { headers: conn.headers, ...init });
5754
+ } catch (err) {
5755
+ if (isNetworkError(err)) fail(EXIT.NETWORK, `network failure reaching ${conn.url}: ${err.message}`);
5756
+ throw err;
5641
5757
  }
5642
5758
  }
5643
5759
  async function postJson(conn, pathname, body) {
5644
- const res = await fetch(`${conn.url}${pathname}`, {
5645
- method: "POST",
5646
- headers: conn.headers,
5647
- body: JSON.stringify(body)
5648
- });
5760
+ const res = await request(conn, pathname, { method: "POST", body: JSON.stringify(body) });
5761
+ const json = await res.json().catch(() => ({}));
5762
+ return { res, json };
5763
+ }
5764
+ async function getJson(conn, pathname) {
5765
+ const res = await request(conn, pathname);
5649
5766
  const json = await res.json().catch(() => ({}));
5650
5767
  return { res, json };
5651
5768
  }
5652
- async function activatePlaybookVersion(conn, playbookVersionId) {
5653
- console.log(`
5654
- ${LOG} Activating playbook version ${playbookVersionId} (submit \u2192 approve \u2192 deploy) \u2026`);
5769
+ async function downloadConfig(conn, playbookVersionId) {
5770
+ const qs = playbookVersionId ? `?playbookVersionId=${encodeURIComponent(playbookVersionId)}` : "";
5771
+ const res = await request(conn, `/api/config/export${qs}`);
5772
+ if (!res.ok) httpFail(conn, "download", res.status);
5773
+ return res.json().catch(() => ({}));
5774
+ }
5775
+ async function requireOpenDraft(conn) {
5776
+ const { ok, status, draft } = await resolveActiveDraft(conn.url, conn.headers);
5777
+ if (!ok) httpFail(conn, "draft lookup", status);
5778
+ if (!draft) fail(EXIT.VALIDATION, "No open draft for this tenant \u2014 stage one with `revturbine upload <file>`.");
5779
+ return draft.id;
5780
+ }
5781
+ async function loadVersion(conn, sel) {
5782
+ switch (sel.kind) {
5783
+ case "file":
5784
+ return loadConfig(sel.path);
5785
+ case "live":
5786
+ return downloadConfig(conn);
5787
+ case "release":
5788
+ return downloadConfig(conn, sel.id);
5789
+ case "draft":
5790
+ return downloadConfig(conn, await requireOpenDraft(conn));
5791
+ }
5792
+ }
5793
+ async function launchDraft(conn, playbookVersionId) {
5794
+ diag(`Launch gate: validating draft ${playbookVersionId} \u2026`);
5795
+ const validation = await fetchValidation(conn.url, playbookVersionId, conn.headers);
5796
+ if (!validation.ok) httpFail(conn, "validate", validation.status, validation.error);
5797
+ if (validation.findings.length > 0) diagRaw(formatFindings(validation.findings));
5798
+ if (hasBlockingFindings(validation.findings)) {
5799
+ fail(EXIT.VALIDATION, "blocking findings \u2014 this draft cannot launch.");
5800
+ }
5801
+ diag(`Launching playbook version ${playbookVersionId} (submit \u2192 approve \u2192 deploy) \u2026`);
5655
5802
  for (const step of ["submit", "approve", "deploy"]) {
5656
5803
  const { res, json } = await postJson(conn, `/api/playbook-versions/${playbookVersionId}/${step}`, {});
5657
5804
  if (!res.ok) {
5658
- console.error(`${LOG} \u2717 ${step} failed (${res.status}). The draft is staged but NOT live.`);
5659
- console.error(` ${JSON.stringify(json)}`);
5660
- console.error(` Finish it from the UI (Drafts & Releases): playbook version ${playbookVersionId}`);
5805
+ diag(`\u2717 ${step} failed (${res.status}). The draft is staged but NOT live.`);
5806
+ diagRaw(` ${JSON.stringify(json)}`);
5807
+ diag(`Finish it from the UI (Drafts & Releases): playbook version ${playbookVersionId}`);
5661
5808
  authHint(conn.url, res.status);
5662
- process.exit(1);
5809
+ process.exit(classFromStatus(res.status));
5663
5810
  }
5664
- console.log(` \u2713 ${step}`);
5811
+ diag(` \u2713 ${step}`);
5665
5812
  }
5666
- console.log(`${LOG} \u2713 Deployed \u2014 the playbook version is now the live configuration.`);
5667
- }
5668
- async function getJson(conn, pathname) {
5669
- const res = await fetch(`${conn.url}${pathname}`, { headers: conn.headers });
5670
- const json = await res.json().catch(() => ({}));
5671
- return { res, json };
5672
- }
5673
- async function downloadLiveConfig(conn, playbookVersionId) {
5674
- const qs = playbookVersionId ? `?playbookVersionId=${encodeURIComponent(playbookVersionId)}` : "";
5675
- const res = await fetch(`${conn.url}/api/config/export${qs}`, { headers: conn.headers });
5676
- const config = res.ok ? await res.json().catch(() => ({})) : {};
5677
- return { res, config };
5813
+ diag("\u2713 Launched \u2014 the playbook version is now the live configuration.");
5678
5814
  }
5679
5815
  async function confirmOrExit(promptText, yes) {
5680
5816
  if (yes) return;
5681
5817
  if (!process.stdin.isTTY) {
5682
- console.error(`${LOG} Refusing a destructive action without confirmation (no TTY). Pass --yes.`);
5683
- process.exit(1);
5818
+ fail(EXIT.USAGE, "Refusing a destructive action without confirmation (no TTY). Pass --yes.");
5684
5819
  }
5685
- const rl = createInterface({ input: process.stdin, output: process.stdout });
5820
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
5686
5821
  const answer = (await rl.question(`${promptText} [y/N] `)).trim().toLowerCase();
5687
5822
  rl.close();
5688
5823
  if (answer !== "y" && answer !== "yes") {
5689
- console.log(`${LOG} Aborted.`);
5824
+ diag("Aborted.");
5690
5825
  process.exit(0);
5691
5826
  }
5692
5827
  }
5828
+ function table(rows) {
5829
+ if (rows.length === 0) return " (none)";
5830
+ const widths = rows[0].map((_, col) => Math.max(...rows.map((r) => (r[col] ?? "").length)));
5831
+ return rows.map((r) => " " + r.map((cell, i) => (cell ?? "").padEnd(widths[i])).join(" ")).join("\n");
5832
+ }
5833
+ var SHOW_KINDS = ["plans", "entitlements", "segments", "placements", "trials"];
5834
+ function renderShow(kind, config) {
5835
+ const arr = (key) => Array.isArray(config[key]) ? config[key] : [];
5836
+ const handleOf = (item) => item.handle ?? item.unique_handle ?? item.id ?? "";
5837
+ switch (kind) {
5838
+ case "plans": {
5839
+ const plans = arr("plans");
5840
+ const addons = arr("addons");
5841
+ const ents = arr("entitlements");
5842
+ const data = { plans, addons, entitlements: ents };
5843
+ const text = [
5844
+ `plans (${plans.length}):`,
5845
+ table(plans.map((p) => [handleOf(p), p.name ?? "", `tier ${p.tier_position ?? "-"}`])),
5846
+ `addons (${addons.length}):`,
5847
+ table(addons.map((a) => [handleOf(a), a.name ?? ""])),
5848
+ `entitlements (${ents.length}):`,
5849
+ table(ents.map((e) => [handleOf(e), e.name ?? "", e.type ?? ""]))
5850
+ ].join("\n");
5851
+ return { data, text };
5852
+ }
5853
+ case "entitlements": {
5854
+ const ents = arr("entitlements");
5855
+ return { data: ents, text: table(ents.map((e) => [handleOf(e), e.name ?? "", e.type ?? ""])) };
5856
+ }
5857
+ case "segments": {
5858
+ const segs = arr("segments");
5859
+ return {
5860
+ data: segs,
5861
+ text: table(segs.map((s) => [handleOf(s), s.name ?? "", `${(s.predicates ?? []).length} predicate(s)`]))
5862
+ };
5863
+ }
5864
+ case "placements": {
5865
+ const pls = arr("placements");
5866
+ return {
5867
+ data: pls,
5868
+ text: table(
5869
+ pls.map((p) => [p.name ?? handleOf(p), p.category ?? "", p.trigger?.type ?? "", `${(p.payloads ?? []).length} payload(s)`])
5870
+ )
5871
+ };
5872
+ }
5873
+ case "trials": {
5874
+ const free = arr("free_trial_rules");
5875
+ const reverse = arr("reverse_trial_rules");
5876
+ const data = { free_trial_rules: free, reverse_trial_rules: reverse };
5877
+ const text = [
5878
+ `free_trial_rules (${free.length}):`,
5879
+ table(free.map((r) => [handleOf(r), r.name ?? ""])),
5880
+ `reverse_trial_rules (${reverse.length}):`,
5881
+ table(reverse.map((r) => [handleOf(r), r.name ?? ""]))
5882
+ ].join("\n");
5883
+ return { data, text };
5884
+ }
5885
+ }
5886
+ }
5693
5887
  var pkgVersion = createRequire(import.meta.url)("../package.json").version ?? "0.0.0";
5694
5888
  var HELP_AFTER = `
5695
5889
  Command groups:
5696
- Auth login, logout
5697
- Validate verify, diff
5698
- Stage/deploy upload, deploy
5699
- Inspect status, preview, export
5700
- Lifecycle submit, approve, reject, discard, rollback
5701
- Runtime/ops evaluate, promote
5890
+ Auth & meta login, logout, signup, whoami, schema, docs
5891
+ Download download
5892
+ Check validate, diff, show
5893
+ Stage/launch upload, launch, discard, rollback
5894
+ Inspect status, history, preview, evaluate
5895
+
5896
+ Version selectors (no defaults \u2014 a command that reads a config requires one):
5897
+ <file> a local Config File (positional, or --file <path>)
5898
+ --draft the tenant's single open draft (resolved automatically)
5899
+ --live the current live Release
5900
+ --release <id> a specific playbook version / Release
5702
5901
 
5703
5902
  Common workflows:
5704
5903
  # Author, validate, and ship against the default instance (revturbine.com/app)
5705
5904
  revturbine login
5706
- revturbine verify ./export-config.json
5707
- revturbine diff ./export-config.json
5708
- revturbine upload ./export-config.json --deploy
5905
+ revturbine download --live --save ./config.json
5906
+ revturbine validate ./config.json
5907
+ revturbine diff ./config.json --live
5908
+ revturbine launch ./config.json
5709
5909
 
5710
- # Target a different instance \u2014 pass its URL (include the /app path)
5711
- revturbine login https://app.example.com/app
5712
- revturbine upload ./export-config.json --url https://app.example.com/app --deploy
5713
-
5714
- # Manual review flow (stage \u2192 inspect \u2192 submit \u2192 approve \u2192 deploy)
5715
- revturbine upload ./export-config.json # stage a draft playbook version
5716
- revturbine preview <playbook-version-id> # inspect runtime impact
5717
- revturbine submit <playbook-version-id>
5718
- revturbine approve <playbook-version-id>
5719
- revturbine deploy <playbook-version-id>
5910
+ # Manual review flow (stage \u2192 inspect \u2192 launch)
5911
+ revturbine upload ./config.json # stage as the open draft
5912
+ revturbine preview # inspect the open draft
5913
+ revturbine validate --draft # full server catalog
5914
+ revturbine launch --draft
5720
5915
 
5721
5916
  # Inspect and roll back
5722
5917
  revturbine status
5918
+ revturbine history
5723
5919
  revturbine rollback <playbook-version-id>
5724
5920
 
5725
- Default instance:
5726
- --url and the \`login\` argument default to https://revturbine.com/app \u2014
5727
- the /app subfolder is required for API routing. Pass an explicit URL (with its
5728
- /app path) to target another instance.
5921
+ Exit-code classes: 0 ok \xB7 1 unexpected \xB7 2 usage \xB7 3 auth \xB7 4 validation
5922
+ blocked \xB7 5 conflict/stale \xB7 6 network \xB7 7 server error.
5729
5923
 
5730
5924
  Auth:
5731
5925
  Most commands need a token \u2014 run \`login\` first. Credentials live at
5732
5926
  ~/.revturbine/credentials.json (0600); the token's tenant is used by default,
5733
- override with -t/--tenant-id. Mutating commands (discard, rollback, promote)
5734
- prompt for confirmation unless --yes.
5927
+ override with -t/--tenant-id. Mutating commands (discard, rollback) prompt
5928
+ for confirmation unless --yes.
5735
5929
 
5736
- Full reference: https://github.com/revt-eng/revturbine-cli#commands
5930
+ Full reference: ${DOCS_URL}
5737
5931
  `;
5738
5932
  var program = new Command();
5739
5933
  program.hook("postAction", async (_thisCommand, actionCommand) => {
5740
5934
  const opts = actionCommand.opts();
5935
+ if (actionCommand.name() === "validate" && !opts.draft) return;
5741
5936
  if (!shouldTrackCommandExecution(actionCommand.name(), Boolean(opts.url))) return;
5742
5937
  await trackEvent(opts.url, opts.tenantId, "cli_command_executed", {
5743
5938
  command: actionCommand.name()
5744
5939
  });
5745
5940
  });
5746
- program.name("revturbine").description("Verify ExportedConfig files and load them into a RevTurbine instance via playbook versions.").version(`${pkgVersion} (schema ${SCHEMA_VERSION})`, "-V, --version", "Print the revturbine and bundled schema versions").showHelpAfterError().addHelpText("after", HELP_AFTER);
5941
+ program.name("revturbine").description("Validate RevTurbine Config Files and ship them through the playbook-version lifecycle (draft \u2192 Release).").version(`${pkgVersion} (schema ${SCHEMA_VERSION})`, "-V, --version", "Print the revturbine and bundled schema versions").showHelpAfterError().addHelpText("after", HELP_AFTER);
5747
5942
  program.command("login").description("Authorize this machine via the browser (device flow) and store a token for the instance (default: the production instance).").argument("[url]", `RevTurbine instance URL (default: ${DEFAULT_URL})`).action(async (url) => {
5748
5943
  try {
5749
5944
  const base = normalizeBaseUrl(url ?? DEFAULT_URL);
5750
5945
  await deviceLogin(base);
5751
5946
  await trackEvent(base, void 0, "cli_signed_in");
5752
5947
  } catch (err) {
5753
- console.error(`${LOG} \u2717 Login failed: ${err.message}`);
5754
- process.exit(1);
5948
+ fail(EXIT.AUTH, `Login failed: ${err.message}`);
5755
5949
  }
5756
5950
  });
5757
5951
  async function promptLine(question) {
5758
- const rl = createInterface({ input: process.stdin, output: process.stdout });
5952
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
5759
5953
  try {
5760
5954
  return (await rl.question(question)).trim();
5761
5955
  } finally {
@@ -5763,10 +5957,10 @@ async function promptLine(question) {
5763
5957
  }
5764
5958
  }
5765
5959
  function promptHidden(question) {
5766
- const { stdin, stdout } = process;
5960
+ const { stdin, stderr } = process;
5767
5961
  if (!stdin.isTTY) return promptLine(question);
5768
5962
  return new Promise((resolve) => {
5769
- stdout.write(question);
5963
+ stderr.write(question);
5770
5964
  const wasRaw = stdin.isRaw;
5771
5965
  stdin.setRawMode(true);
5772
5966
  stdin.resume();
@@ -5782,24 +5976,24 @@ function promptHidden(question) {
5782
5976
  const code = ch.charCodeAt(0);
5783
5977
  if (code === 13 || code === 10 || code === 4) {
5784
5978
  cleanup();
5785
- stdout.write("\n");
5979
+ stderr.write("\n");
5786
5980
  resolve(input.trim());
5787
5981
  return;
5788
5982
  }
5789
5983
  if (code === 3) {
5790
5984
  cleanup();
5791
- stdout.write("\n");
5985
+ stderr.write("\n");
5792
5986
  process.exit(130);
5793
5987
  }
5794
5988
  if (code === 127 || code === 8) {
5795
5989
  if (input.length > 0) {
5796
5990
  input = input.slice(0, -1);
5797
- stdout.write("\b \b");
5991
+ stderr.write("\b \b");
5798
5992
  }
5799
5993
  continue;
5800
5994
  }
5801
5995
  input += ch;
5802
- stdout.write("*");
5996
+ stderr.write("*");
5803
5997
  }
5804
5998
  };
5805
5999
  stdin.on("data", onData);
@@ -5812,8 +6006,7 @@ program.command("signup").description("Create a RevTurbine account and log in (e
5812
6006
  const email = opts.email ?? await promptLine("Email: ");
5813
6007
  const password = opts.password ?? await promptHidden("Password: ");
5814
6008
  if (!name || !email || password.length < 8) {
5815
- console.error(`${LOG} \u2717 Name, email, and a password of at least 8 characters are required.`);
5816
- process.exit(1);
6009
+ fail(EXIT.USAGE, "Name, email, and a password of at least 8 characters are required.");
5817
6010
  }
5818
6011
  const result = await signup({
5819
6012
  baseUrl,
@@ -5825,290 +6018,314 @@ program.command("signup").description("Create a RevTurbine account and log in (e
5825
6018
  if (result.status === "awaiting_invitation") process.exit(0);
5826
6019
  await trackEvent(baseUrl, void 0, "cli_signed_up");
5827
6020
  } catch (err) {
5828
- console.error(`${LOG} \u2717 Signup failed: ${err.message}`);
5829
- process.exit(1);
6021
+ fail(EXIT.AUTH, `Signup failed: ${err.message}`);
5830
6022
  }
5831
6023
  });
5832
6024
  program.command("logout").description("Remove the stored token for <url>.").argument("[url]", `RevTurbine instance URL (default: ${DEFAULT_URL})`).action((url) => {
5833
6025
  const normalized = normalizeBaseUrl(url ?? DEFAULT_URL);
5834
6026
  const removed = removeCredential(normalized);
5835
- console.log(removed ? `${LOG} Logged out of ${normalized}.` : `${LOG} No stored credential for ${normalized}.`);
5836
- });
5837
- program.command("verify").description("Schema-validate one or more ExportedConfig files (offline).").argument("<config...>", "Path(s) to an export-config.json file").action((configs) => {
5838
- const failures = configs.filter((file) => verifyConfig(file) === null).length;
5839
- if (configs.length > 1) console.log(`
5840
- ${LOG} ${configs.length - failures}/${configs.length} passed.`);
5841
- process.exit(failures > 0 ? 1 : 0);
6027
+ diag(removed ? `Logged out of ${normalized}.` : `No stored credential for ${normalized}.`);
5842
6028
  });
5843
- program.command("diff").description("Non-destructive: download the tenant's live config, diff against the local config, and dry-run the import.").argument("<config>", "Path to an export-config.json file").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (configFile2, opts) => {
5844
- const config = verifyConfig(configFile2);
5845
- if (config === null) process.exit(1);
6029
+ program.command("whoami").description("Show the resolved instance, tenant, credentials source, and whether the stored token works.").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) => {
5846
6030
  const conn = connect(opts.url, opts.tenantId);
5847
- console.log(`
5848
- ${LOG} Comparing ${configFile2} against ${conn.url} (tenant ${conn.tenantId})\u2026`);
5849
- const { res: exportRes, config: current } = await downloadLiveConfig(conn);
5850
- if (!exportRes.ok && (exportRes.status === 401 || exportRes.status === 403)) {
5851
- console.error(`${LOG} \u2717 Could not download current config (${exportRes.status}).`);
5852
- authHint(conn.url, exportRes.status);
5853
- process.exit(1);
5854
- } else if (!exportRes.ok) {
5855
- console.log(`${LOG} (no current config \u2014 HTTP ${exportRes.status}; treating tenant as empty)`);
5856
- }
5857
- console.log(`
5858
- ${LOG} Diff (local vs tenant):`);
5859
- console.log(formatDiff(diffExportedConfig(current, config)));
5860
- const { res: dryRes, json: dry } = await postJson(conn, "/api/config/import?dryRun=true", config);
5861
- console.log(`
5862
- ${LOG} Dry-run checks:`, JSON.stringify(dry.checks ?? dry, null, 2));
5863
- const conflicts = Array.isArray(dry.conflicts) ? dry.conflicts : [];
5864
- if (conflicts.length) {
5865
- console.error(`
5866
- ${LOG} \u2717 ${conflicts.length} uniqueness conflict(s):`);
5867
- for (const c of conflicts) {
5868
- const why = c.scope === "existing" ? `handle already used by ${c.existing_id}` : `duplicated within the config (${c.config_id})`;
5869
- console.error(` - ${c.collection}.${c.handle}: ${why}`);
5870
- }
5871
- }
5872
- if (!dryRes.ok || dry.ok === false) {
5873
- authHint(conn.url, dryRes.status);
5874
- console.error(`
5875
- ${LOG} Dry-run found problems. Fix them before uploading.`);
5876
- process.exit(1);
5877
- }
5878
- console.log(
5879
- `
5880
- ${LOG} \u2713 No conflicts. Stage it with:
5881
- revturbine upload ${configFile2} --url ${conn.url} --deploy`
6031
+ let tokenValid = null;
6032
+ if (conn.hasToken) {
6033
+ const probe = await resolveActiveDraft(conn.url, conn.headers).catch(() => ({ ok: false, status: 0, draft: null }));
6034
+ tokenValid = probe.ok || probe.status !== 401 && probe.status !== 403 && probe.status !== 0;
6035
+ }
6036
+ const data = {
6037
+ instance: conn.url,
6038
+ tenant: conn.tenantId,
6039
+ tenant_source: conn.tenantSource,
6040
+ credentials_dir: conn.credentialsDir,
6041
+ credentials_source: conn.credentialsSource,
6042
+ token_present: conn.hasToken,
6043
+ token_valid: tokenValid
6044
+ };
6045
+ emit(
6046
+ data,
6047
+ Boolean(opts.json),
6048
+ [
6049
+ `instance: ${data.instance}`,
6050
+ `tenant: ${data.tenant} (${data.tenant_source})`,
6051
+ `credentials: ${data.credentials_dir} [${data.credentials_source}]`,
6052
+ `token: ${data.token_present ? tokenValid ? "present, valid" : "present, NOT accepted" : "absent \u2014 run `revturbine login`"}`
6053
+ ].join("\n")
5882
6054
  );
5883
6055
  });
5884
- program.command("upload").description("Stage an ExportedConfig file as a draft playbook version (POST /api/config/import). Use --deploy to also activate it.").argument("<config>", "Path to an export-config.json file").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").addOption(new Option("--deploy", "After staging, activate the draft (submit \u2192 approve \u2192 deploy)")).action(async (configFile2, opts) => {
5885
- const config = verifyConfig(configFile2);
5886
- if (config === null) {
5887
- console.error(`
5888
- ${LOG} Fix the issues above in ${configFile2}, then re-run.`);
5889
- process.exit(1);
6056
+ 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
+ try {
6058
+ const jsonSchema = z27.toJSONSchema(RevTurbineConfigSchema, { unrepresentable: "any" });
6059
+ emit({ schema_version: SCHEMA_VERSION, schema: jsonSchema }, true);
6060
+ } catch (err) {
6061
+ fail(EXIT.UNEXPECTED, `could not render the bundled schema (${SCHEMA_VERSION}): ${err.message}`);
5890
6062
  }
5891
- const conn = connect(opts.url, opts.tenantId);
5892
- console.log(`
5893
- ${LOG} Staging ${configFile2} into a draft playbook version (${conn.url}/api/config/import) \u2026`);
5894
- const { res, json } = await postJson(conn, "/api/config/import", config);
5895
- if (!res.ok) {
5896
- console.error(`${LOG} \u2717 Upload failed (${res.status})`);
5897
- if (json && typeof json === "object" && json.stage) {
5898
- console.error(` stage: ${json.stage}`);
5899
- console.error(` detail: ${JSON.stringify(json.detail)}`);
6063
+ });
6064
+ program.command("docs").description("Print the canonical documentation URL (and open it in a browser when interactive).").action(() => {
6065
+ process.stdout.write(`${DOCS_URL}
6066
+ `);
6067
+ if (process.stdout.isTTY) {
6068
+ const [cmd, args] = process.platform === "win32" ? ["cmd", ["/c", "start", "", DOCS_URL]] : process.platform === "darwin" ? ["open", [DOCS_URL]] : ["xdg-open", [DOCS_URL]];
6069
+ try {
6070
+ spawn2(cmd, args, { stdio: "ignore", detached: true }).unref();
6071
+ } catch {
6072
+ }
6073
+ }
6074
+ });
6075
+ 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(
6076
+ async (opts) => {
6077
+ const [sel] = requireSelectors(opts, [], { count: 1, allowed: ["draft", "live", "release"], command: "download" });
6078
+ const conn = connect(opts.url, opts.tenantId);
6079
+ const playbookVersionId = sel.kind === "release" ? sel.id : sel.kind === "draft" ? await requireOpenDraft(conn) : void 0;
6080
+ if (opts.format === "flatbuffer") {
6081
+ const qs = playbookVersionId ? `?playbookVersionId=${encodeURIComponent(playbookVersionId)}` : "";
6082
+ const res = await request(conn, `/api/config/bundle${qs}`);
6083
+ if (!res.ok) httpFail(conn, "bundle download", res.status);
6084
+ const bytes = Buffer.from(await res.arrayBuffer());
6085
+ if (opts.save) {
6086
+ const out2 = path2.resolve(opts.save);
6087
+ mkdirSync2(path2.dirname(out2), { recursive: true });
6088
+ writeFileSync2(out2, bytes);
6089
+ diag(`\u2713 Wrote ${bytes.length} bytes \u2192 ${out2}`);
6090
+ } else {
6091
+ process.stdout.write(`${bytes.toString("base64")}
6092
+ `);
6093
+ }
6094
+ return;
6095
+ }
6096
+ const config = await downloadConfig(conn, playbookVersionId);
6097
+ const out = `${JSON.stringify(config, null, 2)}
6098
+ `;
6099
+ if (opts.save) {
6100
+ const file = path2.resolve(opts.save);
6101
+ mkdirSync2(path2.dirname(file), { recursive: true });
6102
+ writeFileSync2(file, out);
6103
+ diag(`\u2713 Wrote ${file}`);
5900
6104
  } else {
5901
- console.error(` ${JSON.stringify(json)}`);
6105
+ process.stdout.write(out);
5902
6106
  }
5903
- authHint(conn.url, res.status);
5904
- process.exit(1);
5905
6107
  }
5906
- const playbookVersionId = json?.playbook_version_id;
5907
- console.log(`${LOG} \u2713 Staged ${configFile2} as a draft playbook version (${res.status})`);
5908
- console.log(` imported: ${JSON.stringify(json.imported ?? {})}`);
5909
- if (playbookVersionId) console.log(` playbook_version_id: ${playbookVersionId}`);
5910
- if (!opts.deploy) {
5911
- console.log(
5912
- `
5913
- ${LOG} Staged as a draft. Activate it with:
5914
- ` + (playbookVersionId ? ` revturbine deploy ${playbookVersionId} --url ${conn.url}
5915
- ` : "") + " \u2026or from the RevTurbine UI (Drafts & Releases)."
5916
- );
5917
- return;
6108
+ );
6109
+ program.command("validate").description("Validate a Config File offline (schema), or the open draft against the full server catalog (--draft).").argument("[file...]", "Path(s) to a Config File (offline mode)").option("--draft", "Validate the open draft server-side (full catalog)").option("-u, --url <url>", "RevTurbine instance URL (used with --draft)", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (files, opts) => {
6110
+ if (opts.draft && files.length > 0) fail(EXIT.USAGE, "validate takes either <file> or --draft, not both.");
6111
+ if (!opts.draft && files.length === 0) {
6112
+ throw new SelectorError("STATE_REQUIRED \u2014 validate needs a version: <file> (offline) or --draft (server catalog).");
5918
6113
  }
5919
- if (!playbookVersionId) {
5920
- console.warn(
5921
- `${LOG} No playbook_version_id returned \u2014 the server predates the playbook-version wire; nothing to deploy.`
5922
- );
5923
- return;
6114
+ 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);
5924
6118
  }
5925
- await activatePlaybookVersion(conn, playbookVersionId);
5926
- });
5927
- program.command("deploy").description("Activate a staged draft playbook version: submit \u2192 approve \u2192 deploy.").argument("<playbook-version-id>", "The playbook_version_id returned by `upload`").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; deploy has no confirmation prompt").action(async (playbookVersionId, opts) => {
5928
- const conn = connect(opts.url, opts.tenantId);
5929
- await activatePlaybookVersion(conn, playbookVersionId);
5930
- });
5931
- program.command("validate").description("Run config-validation against a staged draft. Prints findings; exits non-zero if any block.").argument("<playbook-version-id>", "The playbook_version_id to validate").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (playbookVersionId, opts) => {
5932
6119
  const conn = connect(opts.url, opts.tenantId);
6120
+ const playbookVersionId = await requireOpenDraft(conn);
5933
6121
  const result = await fetchValidation(conn.url, playbookVersionId, conn.headers);
5934
- if (!result.ok) {
5935
- console.error(`${LOG} \u2717 validate failed (${result.status})${result.error ? `: ${result.error}` : ""}`);
5936
- authHint(conn.url, result.status);
5937
- process.exit(1);
5938
- }
5939
- console.log(`
5940
- ${LOG} Validation for playbook version ${playbookVersionId}:`);
5941
- console.log(formatFindings(result.findings));
6122
+ if (!result.ok) httpFail(conn, "validate", result.status, result.error);
6123
+ diag(`Validation for the open draft (${playbookVersionId}):`);
6124
+ process.stdout.write(`${formatFindings(result.findings)}
6125
+ `);
5942
6126
  if (hasBlockingFindings(result.findings)) {
5943
- const blocking = result.findings.filter((f) => f.severity === "error_draft" || f.severity === "error_publish");
5944
- console.error(`${LOG} \u2717 ${blocking.length} blocking finding(s) \u2014 this draft cannot be deployed.`);
5945
- process.exit(1);
6127
+ const blocking = result.findings.filter((f) => f.severity === "error_draft" || f.severity === "error_launch");
6128
+ fail(EXIT.VALIDATION, `${blocking.length} blocking finding(s) \u2014 this draft cannot launch.`);
5946
6129
  }
5947
- console.log(`${LOG} \u2713 No blocking findings.`);
6130
+ diag("\u2713 No blocking findings.");
5948
6131
  });
5949
- program.command("export").description("Download the tenant's live config. JSON to stdout by default; --save <file> writes it to disk.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").addOption(new Option("-f, --format <format>", "Representation").choices(["json", "flatbuffer"]).default("json")).option("--save <file>", "Write the downloaded config (json) or bundle (flatbuffer) to <file> instead of stdout").option("--playbook-version <id>", "Export a specific playbook version (default: the live configuration)").action(async (opts) => {
5950
- const conn = connect(opts.url, opts.tenantId);
5951
- const qs = opts.playbookVersion ? `?playbookVersionId=${encodeURIComponent(opts.playbookVersion)}` : "";
5952
- if (opts.format === "flatbuffer") {
5953
- const res2 = await fetch(`${conn.url}/api/config/bundle${qs}`, { headers: conn.headers });
5954
- if (!res2.ok) {
5955
- console.error(`${LOG} \u2717 bundle download failed (${res2.status})`);
5956
- authHint(conn.url, res2.status);
5957
- process.exit(1);
5958
- }
5959
- const bytes = Buffer.from(await res2.arrayBuffer());
5960
- if (opts.save) {
5961
- const out2 = path2.resolve(opts.save);
5962
- mkdirSync2(path2.dirname(out2), { recursive: true });
5963
- writeFileSync2(out2, bytes);
5964
- console.log(`${LOG} \u2713 Wrote ${bytes.length} bytes \u2192 ${out2}`);
5965
- } else {
5966
- process.stdout.write(`${bytes.toString("base64")}
6132
+ program.command("diff").description("Compare two config versions (first \u2192 second). Dry-run, no writes.").argument("[file...]", "Local Config File path(s)").option("--draft", "The tenant's open draft").option("--live", "The current live Release").option("--release <id>", "A specific playbook version / Release").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (files, opts) => {
6133
+ const sels = requireSelectors(opts, files, { count: 2, allowed: ["file", "draft", "live", "release"], command: "diff" });
6134
+ const needsServer = sels.some((s) => s.kind !== "file");
6135
+ const conn = needsServer ? connect(opts.url, opts.tenantId) : null;
6136
+ const [a, b] = await Promise.all(sels.map((s) => loadVersion(conn, s)));
6137
+ diag(`Diff (${sels.map((s) => s.kind === "file" ? s.path : s.kind === "release" ? `--release ${s.id}` : `--${s.kind}`).join(" \u2192 ")}):`);
6138
+ process.stdout.write(`${formatDiff(diffExportedConfig(a, b))}
5967
6139
  `);
6140
+ });
6141
+ program.command("show").description(`Render a summary view of a config version. <kind> \u2208 ${SHOW_KINDS.join(" | ")}.`).argument("<kind>", `One of: ${SHOW_KINDS.join(", ")}`).option("--file <path>", "A local Config File").option("--draft", "The tenant's open draft").option("--live", "The current live Release").option("--release <id>", "A specific playbook version / Release").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(
6142
+ async (kind, opts) => {
6143
+ if (!SHOW_KINDS.includes(kind)) {
6144
+ fail(EXIT.USAGE, `unknown kind '${kind}' \u2014 use one of: ${SHOW_KINDS.join(", ")}.`);
5968
6145
  }
5969
- return;
6146
+ const [sel] = requireSelectors(opts, [], { count: 1, allowed: ["file", "draft", "live", "release"], command: "show" });
6147
+ const needsServer = sel.kind !== "file";
6148
+ const conn = needsServer ? connect(opts.url, opts.tenantId) : null;
6149
+ const config = await loadVersion(conn, sel);
6150
+ const { data, text } = renderShow(kind, config);
6151
+ emit(data, Boolean(opts.json), text);
5970
6152
  }
5971
- const { res, config } = await downloadLiveConfig(conn, opts.playbookVersion);
6153
+ );
6154
+ program.command("upload").description("Stage a Config File as the open draft (POST /api/config/import).").argument("<config>", "Path to a Config File").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (configFile2, opts) => {
6155
+ const config = verifyConfig(configFile2);
6156
+ if (config === null) fail(EXIT.VALIDATION, `Fix the issues above in ${configFile2}, then re-run.`);
6157
+ const conn = connect(opts.url, opts.tenantId);
6158
+ diag(`Staging ${configFile2} as the open draft (${conn.url}/api/config/import) \u2026`);
6159
+ const { res, json } = await postJson(conn, "/api/config/import", config);
5972
6160
  if (!res.ok) {
5973
- console.error(`${LOG} \u2717 export failed (${res.status})`);
5974
- authHint(conn.url, res.status);
5975
- process.exit(1);
5976
- }
5977
- const out = `${JSON.stringify(config, null, 2)}
5978
- `;
5979
- if (opts.save) {
5980
- const file = path2.resolve(opts.save);
5981
- mkdirSync2(path2.dirname(file), { recursive: true });
5982
- writeFileSync2(file, out);
5983
- console.log(`${LOG} \u2713 Wrote ${file}`);
5984
- } else {
5985
- process.stdout.write(out);
6161
+ if (json && typeof json === "object" && json.stage) {
6162
+ diagRaw(` stage: ${json.stage}`);
6163
+ diagRaw(` detail: ${JSON.stringify(json.detail)}`);
6164
+ } else {
6165
+ diagRaw(` ${JSON.stringify(json)}`);
6166
+ }
6167
+ httpFail(conn, "upload", res.status);
5986
6168
  }
6169
+ const playbookVersionId = json?.playbook_version_id;
6170
+ diag(`\u2713 Staged ${configFile2} as the open draft (${res.status})`);
6171
+ diagRaw(` imported: ${JSON.stringify(json.imported ?? {})}`);
6172
+ if (playbookVersionId) diagRaw(` playbook_version_id: ${playbookVersionId}`);
6173
+ diag("Launch it with `revturbine launch --draft`, or from the UI (Drafts & Releases).");
5987
6174
  });
5988
- program.command("status").description("Show the tenant's active draft playbook version and recent releases.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (opts) => {
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) => {
6176
+ const [sel] = requireSelectors(opts, file ? [file] : [], { count: 1, allowed: ["file", "draft"], command: "launch" });
5989
6177
  const conn = connect(opts.url, opts.tenantId);
5990
- const drafts = await getJson(conn, "/api/optimization/drafts");
5991
- if (!drafts.res.ok) {
5992
- console.error(`${LOG} \u2717 Could not read drafts (${drafts.res.status})`);
5993
- authHint(conn.url, drafts.res.status);
5994
- process.exit(1);
5995
- }
5996
- console.log(`
5997
- ${LOG} Drafts:`);
5998
- console.log(JSON.stringify(drafts.json, null, 2));
5999
- const rel = await getJson(conn, "/api/optimization/releases");
6000
- console.log(`
6001
- ${LOG} Recent releases:`);
6002
- console.log(JSON.stringify(rel.res.ok ? rel.json : { error: rel.res.status }, null, 2));
6178
+ let playbookVersionId;
6179
+ if (sel.kind === "file") {
6180
+ const config = verifyConfig(sel.path);
6181
+ if (config === null) fail(EXIT.VALIDATION, `Fix the issues above in ${sel.path}, then re-run.`);
6182
+ diag(`Staging ${sel.path} as the open draft \u2026`);
6183
+ const { res, json } = await postJson(conn, "/api/config/import", config);
6184
+ if (!res.ok) {
6185
+ diagRaw(` ${JSON.stringify(json)}`);
6186
+ httpFail(conn, "upload", res.status);
6187
+ }
6188
+ playbookVersionId = json?.playbook_version_id;
6189
+ if (!playbookVersionId) fail(EXIT.SERVER, "No playbook_version_id returned by the import \u2014 cannot launch.");
6190
+ diag(`\u2713 Staged (${playbookVersionId})`);
6191
+ } else {
6192
+ playbookVersionId = await requireOpenDraft(conn);
6193
+ }
6194
+ await launchDraft(conn, playbookVersionId);
6003
6195
  });
6004
- program.command("discard").description("Discard (archive) an open draft playbook version so a new upload can proceed.").argument("<playbook-version-id>", "The draft playbook_version_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("--yes", "Skip the confirmation prompt").action(async (playbookVersionId, opts) => {
6196
+ program.command("discard").description("Discard (archive) the open draft so a fresh one can start.").argument("[playbook-version-id]", "A specific draft id (default: the open draft)").option("--draft", "The open draft (explicit form of the default)").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 (id, opts) => {
6005
6197
  const conn = connect(opts.url, opts.tenantId);
6006
- await confirmOrExit(`Discard draft playbook version ${playbookVersionId} on ${conn.url}?`, !!opts.yes);
6198
+ const playbookVersionId = id ?? await requireOpenDraft(conn);
6199
+ await confirmOrExit(`Discard draft ${playbookVersionId} on ${conn.url}?`, !!opts.yes);
6007
6200
  const { res, json } = await postJson(conn, `/api/playbook-versions/${playbookVersionId}/archive`, {});
6008
- if (!res.ok) {
6009
- console.error(`${LOG} \u2717 discard failed (${res.status}): ${JSON.stringify(json)}`);
6010
- authHint(conn.url, res.status);
6011
- process.exit(1);
6012
- }
6013
- console.log(`${LOG} \u2713 Discarded playbook version ${playbookVersionId}.`);
6201
+ if (!res.ok) httpFail(conn, "discard", res.status, json);
6202
+ diag(`\u2713 Discarded draft ${playbookVersionId}.`);
6014
6203
  });
6015
- program.command("rollback").description("Roll back a deployed playbook version to the prior configuration (creates a reverting playbook version).").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) => {
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) => {
6016
6205
  const conn = connect(opts.url, opts.tenantId);
6017
6206
  await confirmOrExit(
6018
6207
  `Roll back playbook version ${playbookVersionId} on ${conn.url}? This changes the LIVE configuration.`,
6019
6208
  !!opts.yes
6020
6209
  );
6021
6210
  const { res, json } = await postJson(conn, `/api/playbook-versions/${playbookVersionId}/rollback`, {});
6022
- if (!res.ok) {
6023
- console.error(`${LOG} \u2717 rollback failed (${res.status}): ${JSON.stringify(json)}`);
6024
- authHint(conn.url, res.status);
6025
- process.exit(1);
6026
- }
6027
- console.log(`${LOG} \u2713 Rollback requested for playbook version ${playbookVersionId}.`);
6028
- if (json?.playbook_version_id) console.log(` reverting playbook_version_id: ${json.playbook_version_id}`);
6211
+ if (!res.ok) httpFail(conn, "rollback", res.status, json);
6212
+ diag(`\u2713 Rollback requested for playbook version ${playbookVersionId}.`);
6213
+ const reverting = json?.item?.id ?? json?.playbook_version_id;
6214
+ if (reverting) diagRaw(` reverting draft: ${reverting}`);
6029
6215
  });
6030
- program.command("evaluate").description("Run the live config's placement/entitlement decisions for a user context (from a JSON file).").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).requiredOption("--user <file>", "JSON file: { user_id, customer_id?, plan_handle?, placement_ids?, entitlement_handles?, traits?, now_iso? }").option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (opts) => {
6216
+ 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) => {
6031
6217
  const conn = connect(opts.url, opts.tenantId);
6032
- if (!existsSync2(opts.user)) {
6033
- console.error(`${LOG} user file not found: ${opts.user}`);
6034
- process.exit(1);
6035
- }
6036
- let body;
6037
- try {
6038
- body = JSON.parse(readFileSync2(opts.user, "utf8"));
6039
- } catch (err) {
6040
- console.error(`${LOG} invalid JSON in ${opts.user}: ${err.message}`);
6041
- process.exit(1);
6042
- }
6043
- const { res, json } = await postJson(conn, "/api/sdk/evaluate", body);
6044
- if (!res.ok) {
6045
- console.error(`${LOG} \u2717 evaluate failed (${res.status}): ${JSON.stringify(json)}`);
6046
- authHint(conn.url, res.status);
6047
- process.exit(1);
6048
- }
6049
- console.log(JSON.stringify(json, null, 2));
6218
+ const drafts = await getJson(conn, "/api/optimization/drafts");
6219
+ if (!drafts.res.ok) httpFail(conn, "draft lookup", drafts.res.status);
6220
+ const releases = await getJson(conn, "/api/optimization/releases");
6221
+ const items = releases.res.ok && Array.isArray(releases.json?.items) ? releases.json.items : [];
6222
+ const live = items.find((r) => r.status === "live") ?? items[0] ?? null;
6223
+ const draft = drafts.json?.active ?? null;
6224
+ const parked = Array.isArray(drafts.json?.parked) ? drafts.json.parked : [];
6225
+ const data = { live, draft, parked };
6226
+ const liveLine = live ? `v${live.version ?? "?"} "${live.playbook_version_name ?? live.name ?? ""}" (${live.playbook_version_id ?? live.id}) released ${live.released_at ?? "?"}` : "(none)";
6227
+ const draftLine = draft ? `"${draft.name ?? ""}" (${draft.id}) status ${draft.status ?? "draft"}` : "(none)";
6228
+ emit(
6229
+ data,
6230
+ Boolean(opts.json),
6231
+ [
6232
+ `Live release: ${liveLine}`,
6233
+ `Open draft: ${draftLine}`,
6234
+ `Parked: ${parked.length === 0 ? "(none)" : `${parked.length} draft(s)`}`
6235
+ ].join("\n")
6236
+ );
6050
6237
  });
6051
- program.command("preview").description("Show the runtime-impact preview of a draft playbook version.").argument("<playbook-version-id>", "The draft playbook_version_id").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (playbookVersionId, opts) => {
6238
+ program.command("history").description("The Release Version Log: recent Releases, newest first.").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) => {
6052
6239
  const conn = connect(opts.url, opts.tenantId);
6240
+ const { res, json } = await getJson(conn, "/api/optimization/releases");
6241
+ if (!res.ok) httpFail(conn, "history", res.status);
6242
+ const items = Array.isArray(json?.items) ? json.items : [];
6243
+ emit(
6244
+ items,
6245
+ Boolean(opts.json),
6246
+ table(
6247
+ items.map((r) => [
6248
+ `v${r.version ?? "?"}`,
6249
+ r.status ?? "",
6250
+ (r.released_at ?? "").slice(0, 19),
6251
+ r.playbook_version_name ?? r.name ?? "",
6252
+ r.playbook_version_id ?? r.id ?? ""
6253
+ ])
6254
+ )
6255
+ );
6256
+ });
6257
+ program.command("preview").description("The open draft's staged changes (runtime-impact summary once plan 78 lands).").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 (default shape is already JSON)").action(async (opts) => {
6258
+ const conn = connect(opts.url, opts.tenantId);
6259
+ const playbookVersionId = await requireOpenDraft(conn);
6053
6260
  const { res, json } = await getJson(conn, `/api/playbook-versions/${playbookVersionId}/preview`);
6054
- if (!res.ok) {
6055
- console.error(`${LOG} \u2717 preview failed (${res.status}): ${JSON.stringify(json)}`);
6056
- authHint(conn.url, res.status);
6057
- process.exit(1);
6058
- }
6059
- console.log(JSON.stringify(json, null, 2));
6261
+ if (!res.ok) httpFail(conn, "preview", res.status, json);
6262
+ emit(json, true);
6060
6263
  });
6061
- for (const step of ["submit", "approve", "reject"]) {
6062
- program.command(step).description(`Transition a playbook version: ${step}.`).argument("<playbook-version-id>", "The playbook_version_id").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (playbookVersionId, opts) => {
6063
- const conn = connect(opts.url, opts.tenantId);
6064
- const { res, json } = await postJson(conn, `/api/playbook-versions/${playbookVersionId}/${step}`, {});
6065
- if (!res.ok) {
6066
- console.error(`${LOG} \u2717 ${step} failed (${res.status}): ${JSON.stringify(json)}`);
6067
- authHint(conn.url, res.status);
6068
- process.exit(1);
6069
- }
6070
- console.log(`${LOG} \u2713 ${step} \u2192 playbook version ${playbookVersionId}`);
6071
- });
6072
- }
6073
- program.command("promote").description("Promote a compiled config from one environment to another.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).requiredOption("--from <env>", "Source environment id").requiredOption("--to <env>", "Target environment id").option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").option("--yes", "Skip the confirmation prompt").action(async (opts) => {
6264
+ program.command("evaluate").description("Run the live config's placement/entitlement decisions for a user context (from a JSON file). Draft/Release-targeted evaluation lands with plan 131 TASK-7.").option("-u, --url <url>", "RevTurbine instance URL", DEFAULT_URL).requiredOption("--user <file>", "JSON file: { user_id, customer_id?, plan_handle?, placement_ids?, entitlement_handles?, traits?, now_iso? }").option("-t, --tenant-id <id>", "x-tenant-id (defaults to the stored token tenant)").action(async (opts) => {
6074
6265
  const conn = connect(opts.url, opts.tenantId);
6075
- await confirmOrExit(`Promote config ${opts.from} \u2192 ${opts.to} on ${conn.url}?`, !!opts.yes);
6076
- const { res, json } = await postJson(conn, "/api/environments/promote", {
6077
- source_environment_id: opts.from,
6078
- target_environment_id: opts.to
6079
- });
6080
- if (!res.ok) {
6081
- console.error(`${LOG} \u2717 promote failed (${res.status}): ${JSON.stringify(json)}`);
6082
- authHint(conn.url, res.status);
6083
- process.exit(1);
6266
+ if (!existsSync2(opts.user)) fail(EXIT.USAGE, `user file not found: ${opts.user}`);
6267
+ let body;
6268
+ try {
6269
+ body = JSON.parse(readFileSync2(opts.user, "utf8"));
6270
+ } catch (err) {
6271
+ fail(EXIT.VALIDATION, `invalid JSON in ${opts.user}: ${err.message}`);
6084
6272
  }
6085
- console.log(`${LOG} \u2713 Promotion ${opts.from} \u2192 ${opts.to} requested.`);
6273
+ const { res, json } = await postJson(conn, "/api/sdk/evaluate", body);
6274
+ if (!res.ok) httpFail(conn, "evaluate", res.status, json);
6275
+ emit(json, true);
6086
6276
  });
6087
6277
  var COMMAND_EXAMPLES = {
6088
- upload: [
6278
+ download: [
6279
+ "",
6280
+ "Examples:",
6281
+ " revturbine download --live --save ./config.json The live config \u2192 file",
6282
+ " revturbine download --draft The open draft (rendered on demand) \u2192 stdout",
6283
+ " revturbine download --release cs_1a2b3c --format flatbuffer --save ./bundle.fb"
6284
+ ].join("\n"),
6285
+ validate: [
6286
+ "",
6287
+ "Examples:",
6288
+ " revturbine validate ./config.json Offline schema validation (no network)",
6289
+ " revturbine validate --draft Full server catalog against the open draft"
6290
+ ].join("\n"),
6291
+ diff: [
6089
6292
  "",
6090
6293
  "Examples:",
6091
- " revturbine upload ./export-config.json --url <url> Stage as a draft playbook version",
6092
- " revturbine upload ./export-config.json --url <url> --deploy Stage, then submit \u2192 approve \u2192 deploy"
6294
+ " revturbine diff ./config.json --live Local edits vs the live config",
6295
+ " revturbine diff --live --draft What the open draft would change"
6093
6296
  ].join("\n"),
6094
- deploy: ["", "Example:", " revturbine deploy cs_1a2b3c --url <url> Activate a staged draft"].join("\n"),
6095
- export: [
6297
+ show: ["", "Examples:", " revturbine show plans --live", " revturbine show segments --file ./config.json"].join("\n"),
6298
+ upload: ["", "Example:", " revturbine upload ./config.json Stage as the open draft"].join("\n"),
6299
+ launch: [
6096
6300
  "",
6097
6301
  "Examples:",
6098
- " revturbine export --url <url> Active playbook version \u2192 stdout (JSON)",
6099
- " revturbine export --url <url> --save ./export-config.json Write the live config to a file",
6100
- " revturbine export --url <url> --format flatbuffer --save ./bundle.fb Compiled bundle \u2192 file",
6101
- " revturbine export --url <url> --playbook-version cs_1a2b3c A specific playbook version\u2019s frozen snapshot"
6302
+ " revturbine launch ./config.json Upload, gate, and go live",
6303
+ " revturbine launch --draft Launch the already-open draft"
6102
6304
  ].join("\n"),
6103
6305
  evaluate: [
6104
6306
  "",
6105
6307
  "Example:",
6106
6308
  " revturbine evaluate --url <url> --user ./ctx.json",
6107
6309
  ' ctx.json: { "user_id": "u1", "plan_handle": "pro", "entitlement_handles": ["seats"] }'
6108
- ].join("\n"),
6109
- promote: ["", "Example:", " revturbine promote --url <url> --from production --to staging"].join("\n")
6310
+ ].join("\n")
6110
6311
  };
6111
6312
  for (const [name, text] of Object.entries(COMMAND_EXAMPLES)) {
6112
6313
  program.commands.find((c) => c.name() === name)?.addHelpText("after", text);
6113
6314
  }
6114
- await program.parseAsync(process.argv);
6315
+ program.exitOverride();
6316
+ try {
6317
+ await program.parseAsync(process.argv);
6318
+ } catch (err) {
6319
+ if (err instanceof CommanderError) {
6320
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version" || err.code === "commander.help") {
6321
+ process.exit(0);
6322
+ }
6323
+ process.exit(EXIT.USAGE);
6324
+ }
6325
+ if (err instanceof SelectorError) {
6326
+ console.error(`[revturbine] \u2717 ${err.message}`);
6327
+ process.exit(EXIT.USAGE);
6328
+ }
6329
+ console.error(`[revturbine] \u2717 ${err.message}`);
6330
+ process.exit(EXIT.UNEXPECTED);
6331
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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": {