@revturbine/cli 0.19.5 → 0.20.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 +33 -3
  2. package/dist/cli.js +170 -31
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -65,13 +65,13 @@ Commands that read a config name the version explicitly — there is no default:
65
65
  |---|---|
66
66
  | `init` (alias `create`) | Set up RevTurbine while preserving existing integrations: install missing packages, pin the CLI exactly, create a starter for a fresh integration, and install skills for one agent. Use `--scaffold` to create a missing root starter explicitly; existing files are never overwritten. `--agent claude-code\|cursor\|codex` overrides environment detection; an unknown agent skips skills and prints the selection command. Also supports `--dir`, `--dry-run`, `--no-skills`, `--json`. With no `package.json`, offers a new project (`--yes` for noninteractive creation). Runs the invoked CLI even inside a repo that pins a different one. |
67
67
  | `signup` | Create an account headlessly: email + password, then an emailed one-time code to verify, then a token is stored. |
68
- | `login` / `logout` | Device-flow auth; tokens stored at `~/.revturbine/credentials.json` (mode 0600). |
69
- | `whoami` | Authentication state first: not logged in, credentials accepted, credentials rejected for the selected instance/tenant, or verification unavailable. Then shows instance, tenant, and credentials source; configured tenant information alone does not prove login. Verification and subsequent telemetry each time out after 3 seconds. Status reports exit 0. `--json` retains its seven fields: `instance`, `tenant`, `tenant_source`, `credentials_dir`, `credentials_source`, `token_present` (boolean), and `token_valid` (`true` for 2xx, `false` for 401/403, `null` when absent or unverified, including redirects, other HTTP errors, timeouts and network failures). |
68
+ | `login` / `logout` | Device-flow auth; tokens stored at `~/.revturbine/credentials.json` (mode 0600). For CI, skip login and set `REVTURBINE_TOKEN` (see [CI / non-interactive use](#ci--non-interactive-use)). |
69
+ | `whoami` | Authentication state first: not logged in, credentials accepted, credentials rejected for the selected instance/tenant, or verification unavailable. Then shows instance, tenant, and credentials source; configured tenant information alone does not prove login. Verification and subsequent telemetry each time out after 3 seconds. Status reports exit 0. `--json` retains its seven fields: `instance`, `tenant` (`null` when `REVTURBINE_TOKEN` is set without `-t`: the server resolves the token user's own tenant, and `tenant_source` names the variable), `tenant_source`, `credentials_dir`, `credentials_source`, `token_present` (boolean), and `token_valid` (`true` for 2xx, `false` for 401/403, `null` when absent or unverified, including redirects, other HTTP errors, timeouts and network failures). |
70
70
  | `schema` | Emit the bundled `RevTurbineConfig` JSON schema (for agents to author against). |
71
71
  | `docs` | Print the canonical documentation URL. |
72
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
- | `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. |
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. `--exit-code` makes the status machine-readable: `0` no differences, `1` differences, anything else an error (an unexpected internal error exits `70` so `1` always means "differences"). Every top-level Playbook collection is compared. |
75
75
  | `show <kind>` | Summary tables: `plans` · `entitlements` · `segments` · `placements` · `trials` for any version. |
76
76
  | `upload` | Stage a Playbook file as the open draft. |
77
77
  | `launch` | Take a config live: validate (launch gate) → submit → approve → deploy. `launch <file>` or `launch --draft`. |
@@ -145,6 +145,33 @@ Public ingest tokens are intentionally embeddable but remain tenant-bound,
145
145
  origin-restricted, and optionally IP-restricted. They cannot mint other tokens
146
146
  or access private control-plane APIs.
147
147
 
148
+ ## CI / non-interactive use
149
+
150
+ `login` and `signup` are interactive. In CI, supply the token through the
151
+ environment instead:
152
+
153
+ | Variable | Meaning |
154
+ |---|---|
155
+ | `REVTURBINE_TOKEN` | Bearer token for every request. Wins over any stored credential (which is then ignored). With it set and no `-t/--tenant-id`, **no `x-tenant-id` header is sent** and the server resolves the token user's own tenant; `-t` still sends one (it must be that tenant). |
156
+ | `REVTURBINE_HTTP_HEADERS` | A JSON object of extra headers added to every request to the RevTurbine instance, e.g. `{"x-vercel-protection-bypass":"…"}` to reach a protection-gated preview/staging deployment. It may not set `Authorization`, `x-tenant-id`, `x-rt-tenant-id` or `Content-Type`. A malformed value exits `2`. |
157
+
158
+ Neither the token nor any header value is ever logged, echoed in an error, or
159
+ sent as telemetry: diagnostics name only the variable and the header names, and
160
+ any stderr line that would contain a registered secret shows `<redacted>`.
161
+
162
+ ```bash
163
+ export REVTURBINE_TOKEN="$RT_TOKEN"
164
+ export REVTURBINE_HTTP_HEADERS="{\"x-vercel-protection-bypass\":\"$VERCEL_BYPASS\"}"
165
+ URL=https://staging.example.com/app
166
+ if revturbine diff ./seed.playbook.json --live --url "$URL" --exit-code; then
167
+ echo "no changes: skip launch"
168
+ else
169
+ status=$?
170
+ [ "$status" -eq 1 ] || exit "$status" # 2-7 / 70 = a real error
171
+ revturbine launch ./seed.playbook.json --url "$URL"
172
+ fi
173
+ ```
174
+
148
175
  ## Exit-code classes
149
176
 
150
177
  ```
@@ -158,6 +185,9 @@ or access private control-plane APIs.
158
185
  7 server error
159
186
  ```
160
187
 
188
+ `diff --exit-code` is the one exception: `1` means "differences" there, and an
189
+ unexpected internal error exits `70` instead.
190
+
161
191
  ## Schema validation
162
192
 
163
193
  Validation is **mandatory** and runs fully offline against a vendored,
package/dist/cli.js CHANGED
@@ -15609,8 +15609,8 @@ function openBrowser(url) {
15609
15609
  } catch {
15610
15610
  }
15611
15611
  }
15612
- async function deviceLogin(baseUrl, log = console.log) {
15613
- const start = await startDeviceAuth(baseUrl, os2.hostname(), fetch);
15612
+ async function deviceLogin(baseUrl, log = console.log, fetchImpl = fetch) {
15613
+ const start = await startDeviceAuth(baseUrl, os2.hostname(), fetchImpl);
15614
15614
  log("");
15615
15615
  log(`To authorize this device, visit:
15616
15616
  ${start.verification_uri}`);
@@ -15622,7 +15622,7 @@ async function deviceLogin(baseUrl, log = console.log) {
15622
15622
  deviceCode: start.device_code,
15623
15623
  intervalSeconds: start.interval,
15624
15624
  expiresInSeconds: start.expires_in,
15625
- fetchImpl: fetch,
15625
+ fetchImpl,
15626
15626
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
15627
15627
  onPending: () => process.stdout.write(".")
15628
15628
  });
@@ -15717,6 +15717,77 @@ Account created. Enter the verification code emailed to ${opts.email}.`);
15717
15717
  return { status: "signed_in", token };
15718
15718
  }
15719
15719
 
15720
+ // src/lib/env-auth.ts
15721
+ var TOKEN_ENV = "REVTURBINE_TOKEN";
15722
+ var HEADERS_ENV = "REVTURBINE_HTTP_HEADERS";
15723
+ var DEFAULT_TENANT_ID = "dev-tenant-001";
15724
+ function resolveAuth(params) {
15725
+ const envToken = params.envToken?.trim();
15726
+ const explicit = params.explicitTenantId;
15727
+ if (envToken) {
15728
+ return {
15729
+ token: envToken,
15730
+ tokenSource: "env",
15731
+ tenantId: explicit ?? null,
15732
+ tenantSource: explicit ? "--tenant-id" : `${TOKEN_ENV}; resolved server-side`
15733
+ };
15734
+ }
15735
+ const stored = params.stored;
15736
+ return {
15737
+ token: stored?.token ?? null,
15738
+ tokenSource: stored ? "stored" : "none",
15739
+ tenantId: explicit ?? stored?.tenant_id ?? DEFAULT_TENANT_ID,
15740
+ tenantSource: explicit ? "--tenant-id" : stored?.tenant_id ? "stored token" : "default"
15741
+ };
15742
+ }
15743
+ var RESERVED = /* @__PURE__ */ new Map([
15744
+ ["authorization", `use ${TOKEN_ENV}`],
15745
+ ["x-tenant-id", "use -t/--tenant-id"],
15746
+ ["x-rt-tenant-id", "use -t/--tenant-id"],
15747
+ ["content-type", "set by the CLI"]
15748
+ ]);
15749
+ var HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
15750
+ function parseExtraHeaders(raw) {
15751
+ if (raw === void 0 || raw.trim() === "") return { ok: true, headers: {} };
15752
+ let parsed;
15753
+ try {
15754
+ parsed = JSON.parse(raw);
15755
+ } catch {
15756
+ return { ok: false, error: `${HEADERS_ENV} is not valid JSON (expected an object of header name \u2192 string value)` };
15757
+ }
15758
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
15759
+ return { ok: false, error: `${HEADERS_ENV} must be a JSON object of header name \u2192 string value` };
15760
+ }
15761
+ const headers = {};
15762
+ for (const [name, value] of Object.entries(parsed)) {
15763
+ if (!HEADER_NAME.test(name)) {
15764
+ return { ok: false, error: `${HEADERS_ENV} has an invalid header name (${JSON.stringify(name.slice(0, 64))})` };
15765
+ }
15766
+ const reserved = RESERVED.get(name.toLowerCase());
15767
+ if (reserved) {
15768
+ return { ok: false, error: `${HEADERS_ENV} may not set ${name} (${reserved})` };
15769
+ }
15770
+ if (typeof value !== "string") {
15771
+ return { ok: false, error: `${HEADERS_ENV} header ${name} must have a string value` };
15772
+ }
15773
+ if (/[\r\n\0]/.test(value)) {
15774
+ return { ok: false, error: `${HEADERS_ENV} header ${name} has a value with a control character` };
15775
+ }
15776
+ headers[name] = value;
15777
+ }
15778
+ return { ok: true, headers };
15779
+ }
15780
+ function withExtraHeaders(fetchImpl, extra) {
15781
+ if (Object.keys(extra).length === 0) return fetchImpl;
15782
+ return (input, init) => {
15783
+ const headers = new Headers(init?.headers);
15784
+ for (const [name, value] of Object.entries(extra)) {
15785
+ if (!headers.has(name)) headers.set(name, value);
15786
+ }
15787
+ return fetchImpl(input, { ...init, headers });
15788
+ };
15789
+ }
15790
+
15720
15791
  // src/lib/track.ts
15721
15792
  var CLI_UNTRACKED_COMMANDS = /* @__PURE__ */ new Set(["login", "signup", "logout"]);
15722
15793
  function shouldTrackCommandExecution(name, hasUrl) {
@@ -15725,14 +15796,16 @@ function shouldTrackCommandExecution(name, hasUrl) {
15725
15796
  async function trackEvent(rawUrl, explicitTenantId, eventType, payload, signal) {
15726
15797
  try {
15727
15798
  const url = normalizeBaseUrl(rawUrl);
15728
- const cred = getCredential(url);
15729
- if (!cred) return;
15799
+ const auth = resolveAuth({ envToken: process.env[TOKEN_ENV], stored: getCredential(url), explicitTenantId });
15800
+ if (!auth.token) return;
15801
+ const extra = parseExtraHeaders(process.env[HEADERS_ENV]);
15730
15802
  await fetch(`${url}/api/events`, {
15731
15803
  method: "POST",
15732
15804
  headers: {
15805
+ ...extra.ok ? extra.headers : {},
15733
15806
  "Content-Type": "application/json",
15734
- "x-tenant-id": explicitTenantId ?? cred.tenant_id ?? "dev-tenant-001",
15735
- Authorization: `Bearer ${cred.token}`
15807
+ ...auth.tenantId ? { "x-tenant-id": auth.tenantId } : {},
15808
+ Authorization: `Bearer ${auth.token}`
15736
15809
  },
15737
15810
  body: JSON.stringify({ event_type: eventType, payload }),
15738
15811
  ...signal ? { signal } : {}
@@ -15755,12 +15828,21 @@ var COLLECTIONS = [
15755
15828
  // BL-0176 / BL-0178: first-class objectives, keyed by handle.
15756
15829
  "objectives"
15757
15830
  ];
15831
+ function collectionsOf(current, next) {
15832
+ const named = new Set(COLLECTIONS);
15833
+ const extra = /* @__PURE__ */ new Set();
15834
+ for (const side of [current, next]) {
15835
+ if (!side || typeof side !== "object") continue;
15836
+ for (const [k, v] of Object.entries(side)) if (Array.isArray(v) && !named.has(k)) extra.add(k);
15837
+ }
15838
+ return [...COLLECTIONS, ...[...extra].sort()];
15839
+ }
15758
15840
  function keyOf(item) {
15759
15841
  return item?.handle ?? item?.unique_handle ?? item?.name ?? item?.id ?? JSON.stringify(item);
15760
15842
  }
15761
15843
  function diffExportedConfig(current, next) {
15762
15844
  const diff = {};
15763
- for (const coll of COLLECTIONS) {
15845
+ for (const coll of collectionsOf(current, next)) {
15764
15846
  const cur = Array.isArray(current?.[coll]) ? current[coll] : [];
15765
15847
  const nxt = Array.isArray(next?.[coll]) ? next[coll] : [];
15766
15848
  if (cur.length === 0 && nxt.length === 0) continue;
@@ -16814,11 +16896,21 @@ function isNetworkError(err) {
16814
16896
  return err instanceof TypeError || err instanceof Error && /fetch failed|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN/i.test(err.message);
16815
16897
  }
16816
16898
  var LOG = "[revturbine]";
16899
+ var secrets = /* @__PURE__ */ new Set();
16900
+ function registerSecrets(values) {
16901
+ for (const v of values) if (v.length >= 4) secrets.add(v);
16902
+ }
16903
+ function redact(message) {
16904
+ if (secrets.size === 0) return message;
16905
+ let out = message;
16906
+ for (const s of [...secrets].sort((a, b) => b.length - a.length)) out = out.split(s).join("<redacted>");
16907
+ return out;
16908
+ }
16817
16909
  function diag(message) {
16818
- console.error(`${LOG} ${message}`);
16910
+ console.error(redact(`${LOG} ${message}`));
16819
16911
  }
16820
16912
  function diagRaw(message) {
16821
- console.error(message);
16913
+ console.error(redact(message));
16822
16914
  }
16823
16915
  function emit(data, json, humanText) {
16824
16916
  if (json) {
@@ -16830,9 +16922,13 @@ function emit(data, json, humanText) {
16830
16922
  }
16831
16923
  }
16832
16924
  function fail(cls, message) {
16833
- console.error(`${LOG} \u2717 ${message}`);
16925
+ console.error(redact(`${LOG} \u2717 ${message}`));
16834
16926
  process.exit(cls);
16835
16927
  }
16928
+ var DIFF_EXIT = { NO_DIFFERENCES: 0, DIFFERENCES: 1, UNEXPECTED: 70 };
16929
+ function diffExitCode(diff) {
16930
+ return Object.keys(diff).length === 0 ? DIFF_EXIT.NO_DIFFERENCES : DIFF_EXIT.DIFFERENCES;
16931
+ }
16836
16932
 
16837
16933
  // src/lib/pin-drift.ts
16838
16934
  function isRecord7(value) {
@@ -17102,14 +17198,33 @@ function verifyConfig(file) {
17102
17198
  if (check.advisories.length > 0) diagRaw(formatFindings(check.advisories));
17103
17199
  return check.body;
17104
17200
  }
17201
+ var extraHeadersCache;
17202
+ function extraHeaders() {
17203
+ if (extraHeadersCache) return extraHeadersCache;
17204
+ const parsed = parseExtraHeaders(process.env[HEADERS_ENV]);
17205
+ if (!parsed.ok) fail(EXIT.USAGE, parsed.error);
17206
+ registerSecrets(Object.values(parsed.headers));
17207
+ extraHeadersCache = parsed.headers;
17208
+ return parsed.headers;
17209
+ }
17210
+ function instanceFetch() {
17211
+ return withExtraHeaders(fetch, extraHeaders());
17212
+ }
17105
17213
  function connect(rawUrl, explicitTenantId, diagnostic = diag) {
17106
17214
  const url = normalizeBaseUrl(rawUrl);
17107
17215
  const { dir, source } = resolveConfigDir();
17108
- const cred = getCredential(url);
17109
- const tenantId = explicitTenantId ?? cred?.tenant_id ?? "dev-tenant-001";
17110
- const tenantSource = explicitTenantId ? "--tenant-id" : cred?.tenant_id ? "stored token" : "default";
17111
- diagnostic(`Tenant ${tenantId} (${tenantSource}); credentials: ${dir} [${source}].`);
17112
- if (source === "global") {
17216
+ const extra = extraHeaders();
17217
+ const envToken = process.env[TOKEN_ENV];
17218
+ if (envToken?.trim()) registerSecrets([envToken.trim()]);
17219
+ const auth = resolveAuth({ envToken, stored: getCredential(url), explicitTenantId });
17220
+ const { tenantId, tenantSource } = auth;
17221
+ const extraNote = Object.keys(extra).length ? `; extra headers: ${Object.keys(extra).join(", ")} [${HEADERS_ENV}, values redacted]` : "";
17222
+ if (auth.tokenSource === "env") {
17223
+ diagnostic(`Tenant ${tenantId ?? "(the token user's own)"} (${tenantSource}); token: ${TOKEN_ENV}${extraNote}.`);
17224
+ } else {
17225
+ diagnostic(`Tenant ${tenantId} (${tenantSource}); credentials: ${dir} [${source}]${extraNote}.`);
17226
+ }
17227
+ if (source === "global" && auth.tokenSource !== "env") {
17113
17228
  diagnostic(
17114
17229
  `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.`
17115
17230
  );
@@ -17120,20 +17235,27 @@ function connect(rawUrl, explicitTenantId, diagnostic = diag) {
17120
17235
  tenantSource,
17121
17236
  credentialsDir: dir,
17122
17237
  credentialsSource: source,
17123
- hasToken: Boolean(cred),
17238
+ hasToken: auth.token !== null,
17239
+ tokenSource: auth.tokenSource,
17124
17240
  headers: {
17241
+ ...extra,
17125
17242
  "Content-Type": "application/json",
17126
- "x-tenant-id": tenantId,
17127
- ...cred ? { Authorization: `Bearer ${cred.token}` } : {}
17243
+ ...tenantId ? { "x-tenant-id": tenantId } : {},
17244
+ ...auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
17128
17245
  }
17129
17246
  };
17130
17247
  }
17131
17248
  function uploadTenantFor(rawUrl, config, explicit) {
17249
+ const embedded = config?.tenant_id;
17250
+ if (process.env[TOKEN_ENV]?.trim()) {
17251
+ if (!explicit && embedded) diag(`sending to the config's embedded tenant ${embedded} (pass -t to retarget).`);
17252
+ return explicit ?? embedded ?? void 0;
17253
+ }
17132
17254
  const cred = getCredential(normalizeBaseUrl(rawUrl));
17133
17255
  const target = resolveUploadTarget({
17134
- embedded: config?.tenant_id,
17256
+ embedded,
17135
17257
  explicit,
17136
- session: cred?.tenant_id ?? "dev-tenant-001"
17258
+ session: cred?.tenant_id ?? DEFAULT_TENANT_ID
17137
17259
  });
17138
17260
  if (!target.ok) fail(EXIT.VALIDATION, target.error);
17139
17261
  if (target.note) diag(target.note);
@@ -17141,8 +17263,10 @@ function uploadTenantFor(rawUrl, config, explicit) {
17141
17263
  }
17142
17264
  function authHint(url, status) {
17143
17265
  if (status === 401 || status === 403) {
17144
- diag(`Authentication required for ${url}. Log in with:
17145
- revturbine login ${url}`);
17266
+ diag(
17267
+ process.env[TOKEN_ENV]?.trim() ? `Authentication failed for ${url} with ${TOKEN_ENV}: check the token and its tenant membership.` : `Authentication required for ${url}. Log in with:
17268
+ revturbine login ${url}`
17269
+ );
17146
17270
  }
17147
17271
  }
17148
17272
  function httpFail(conn, what, status, detail) {
@@ -17378,6 +17502,16 @@ Auth:
17378
17502
  ~/.revturbine/credentials.json (0600); the token's tenant is used by default,
17379
17503
  override with -t/--tenant-id. Mutating commands (discard, restore) prompt
17380
17504
  for confirmation unless --yes.
17505
+
17506
+ CI / non-interactive:
17507
+ REVTURBINE_TOKEN bearer token; wins over the stored credential. Without
17508
+ -t no x-tenant-id is sent (the server resolves the
17509
+ token user's own tenant).
17510
+ REVTURBINE_HTTP_HEADERS JSON object of extra headers for every request, e.g.
17511
+ {"x-vercel-protection-bypass":"..."}.
17512
+ Neither the token nor any header value is ever logged or echoed.
17513
+ \`diff ... --exit-code\` exits 0 = no differences, 1 = differences, other = error
17514
+ (an unexpected error exits 70 there).
17381
17515
  \`whoami\` leads with absent, accepted, rejected, or unverifiable credentials;
17382
17516
  a configured tenant alone does not prove login. Verification and subsequent
17383
17517
  telemetry each time out after 3 seconds. Status reports exit 0; --json keeps
@@ -17392,6 +17526,7 @@ SDK updates:
17392
17526
  Full reference: ${DOCS_URL}
17393
17527
  `;
17394
17528
  var program = new Command();
17529
+ var unexpectedExit = EXIT.UNEXPECTED;
17395
17530
  program.hook("postAction", async (_thisCommand, actionCommand) => {
17396
17531
  const opts = actionCommand.opts();
17397
17532
  if (actionCommand.name() === "validate" && !opts.draft) return;
@@ -17564,7 +17699,7 @@ program.command("init").alias("create").description("Set up RevTurbine while pre
17564
17699
  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) => {
17565
17700
  try {
17566
17701
  const base = normalizeBaseUrl(url ?? DEFAULT_URL);
17567
- await deviceLogin(base);
17702
+ await deviceLogin(base, console.log, instanceFetch());
17568
17703
  await trackEvent(base, void 0, "cli_signed_in");
17569
17704
  } catch (err) {
17570
17705
  fail(EXIT.AUTH, `Login failed: ${err.message}`);
@@ -17635,7 +17770,8 @@ program.command("signup").description("Create a RevTurbine account and log in (e
17635
17770
  name,
17636
17771
  email,
17637
17772
  password,
17638
- promptOtp: (attempt) => promptLine(attempt > 1 ? "Verification code (try again): " : "Verification code: ")
17773
+ promptOtp: (attempt) => promptLine(attempt > 1 ? "Verification code (try again): " : "Verification code: "),
17774
+ fetchImpl: instanceFetch()
17639
17775
  });
17640
17776
  if (result.status === "awaiting_invitation") process.exit(0);
17641
17777
  await trackEvent(baseUrl, void 0, "cli_signed_up");
@@ -17679,8 +17815,8 @@ program.command("whoami").description("Show authentication state first (absent,
17679
17815
  [
17680
17816
  formatAuthentication(conn.hasToken, tokenValid),
17681
17817
  `instance: ${data.instance}`,
17682
- `tenant: ${data.tenant} (${data.tenant_source})`,
17683
- `credentials: ${data.credentials_dir} [${data.credentials_source}]`
17818
+ `tenant: ${data.tenant ?? "(the token user's own)"} (${data.tenant_source})`,
17819
+ conn.tokenSource === "env" ? `credentials: ${TOKEN_ENV} (env; stored credentials ignored)` : `credentials: ${data.credentials_dir} [${data.credentials_source}]`
17684
17820
  ].join("\n")
17685
17821
  );
17686
17822
  for (const message of diagnostics) diag(message);
@@ -17765,15 +17901,18 @@ program.command("validate").description("Validate a Playbook file offline (schem
17765
17901
  }
17766
17902
  diag("\u2713 No blocking findings.");
17767
17903
  });
17768
- program.command("diff").description("Compare two config versions (first \u2192 second; a file vs --draft/--live/--release previews the launch \u2014 the server side is the base). Dry-run, no writes.").argument("[file...]", "Local Playbook 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) => {
17904
+ program.command("diff").description("Compare two config versions (first \u2192 second; a file vs --draft/--live/--release previews the launch \u2014 the server side is the base). Dry-run, no writes.").argument("[file...]", "Local Playbook 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)").option("--exit-code", "Exit 0 when there are no differences, 1 when there are (any other code is an error); for CI no-op detection").action(async (files, opts) => {
17905
+ if (opts.exitCode) unexpectedExit = DIFF_EXIT.UNEXPECTED;
17769
17906
  const sels = requireSelectors(opts, files, { count: 2, allowed: ["file", "draft", "live", "release"], command: "diff" });
17770
17907
  const ordered = orderDiffSelectors(sels);
17771
17908
  const needsServer = ordered.some((s) => s.kind !== "file");
17772
17909
  const conn = needsServer ? connect(opts.url, opts.tenantId) : null;
17773
17910
  const [a, b] = await Promise.all(ordered.map((s) => loadVersion(conn, s)));
17774
17911
  diag(`Diff (${ordered.map((s) => s.kind === "file" ? s.path : s.kind === "release" ? `--release ${s.id}` : `--${s.kind}`).join(" \u2192 ")}):`);
17775
- process.stdout.write(`${formatDiff(diffExportedConfig(a, b))}
17912
+ const diff = diffExportedConfig(a, b);
17913
+ process.stdout.write(`${formatDiff(diff)}
17776
17914
  `);
17915
+ if (opts.exitCode) process.exitCode = diffExitCode(diff);
17777
17916
  });
17778
17917
  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 Playbook 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(
17779
17918
  async (kind, opts) => {
@@ -18365,7 +18504,7 @@ if (process.argv.includes("-V") || process.argv.includes("--version")) {
18365
18504
  console.error(`[revturbine] \u2717 ${err.message}`);
18366
18505
  process.exit(EXIT.USAGE);
18367
18506
  }
18368
- console.error(`[revturbine] \u2717 ${err.message}`);
18369
- process.exit(EXIT.UNEXPECTED);
18507
+ console.error(redact(`[revturbine] \u2717 ${err.message}`));
18508
+ process.exit(unexpectedExit);
18370
18509
  }
18371
18510
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revturbine/cli",
3
- "version": "0.19.5",
3
+ "version": "0.20.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": {