@datarails-ai/dr-cli 1.6.98 → 1.6.100

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.
package/README.md CHANGED
@@ -477,6 +477,60 @@ plugins tree (manifests, frontmatter, versions, the facts file, `CONTRIBUTING.md
477
477
  `node --test plugins/dr-cli/hooks/tests/auto-update.test.js` runs the hook's
478
478
  contract suite.
479
479
 
480
+ ### The endpoint inventory
481
+
482
+ `scopes-endpoints.snapshot.json` lists, for every leaf command, the HTTP endpoints it
483
+ can reach — a top-level, non-empty JSON array of
484
+ `{command, method, url_template, service, certainty, non_integer_params?}` records.
485
+ It is the input of the scope contract gate in dr-datarails-server
486
+ (`HippiWeb/JWT_auth/scope_contract.py`). Today that gate checks that each record's URL
487
+ resolves to a monolith view and that the view declares its scope; holding a command's
488
+ endpoints against its ceiling in `scopes-map.snapshot.json` is what DR-53487 may add.
489
+ `pnpm gen:endpoints` also writes the same bytes to `dist/scopes-endpoints.json`.
490
+
491
+ - **Superset rule.** The list may hold an endpoint a command never actually sends, but
492
+ never omits one it can send: a gate built on it may over-report, never under-report.
493
+ - **`certainty`.** `static` was read from the call and linked to the leaf through the
494
+ call graph; `inferred` is a curated or fallback attribution.
495
+ - **`url_template`.** A path only, no host and no query; a dynamic segment is `{name}`.
496
+ - **`non_integer_params`.** The placeholders whose value is not a number (uuid, token,
497
+ slug…), so substituting a literal `1` will not resolve that route. Absent means every
498
+ placeholder takes an integer.
499
+ - **`service`.** Which service serves the path: `monolith`, `finance-os`,
500
+ `spend-control`, `multi-product`, `desk`, `connectors`, `onboarding-validation`.
501
+ A consumer checking one service filters on it.
502
+ - **`command: "*"`.** A call any authenticated command may trigger (JWT mint/refresh,
503
+ API-key and login-as re-exchange).
504
+ - **Leaves with no record** are explained in `scripts/scopes-map.sources.json` under
505
+ `endpoints`: `noHttpCommands` (no Datarails call) and `externalCommands` (the App Space
506
+ leaves mounted from `@datarails-ai/dr-appspace-cli`, which owns its own contract).
507
+
508
+ ```
509
+ pnpm gen:endpoints # rewrite the snapshot (and dist/scopes-endpoints.json)
510
+ pnpm check:endpoints # what CI runs: exit 1 on drift
511
+ ```
512
+
513
+ The generator is static analysis plus the curated tables in `scopes-map.sources.json`;
514
+ its header (`scripts/gen-endpoints.mts`) documents each step and every fatal case.
515
+ `scripts/gen-endpoints.spotcheck.test.mts` runs a few leaves under msw and checks the
516
+ requests they send are in the snapshot — a sample, not the mechanism. Adding a command
517
+ or an api call changes the snapshot: run `pnpm gen:endpoints` and commit the diff.
518
+
519
+ **Transferring it to dr-datarails-server.** The snapshot is copied by hand, with the
520
+ dr-cli version it was taken at (the `version` in `package.json` at that commit), assuming
521
+ both repos are checked out side by side:
522
+
523
+ ```bash
524
+ cp dr-cli/scopes-endpoints.snapshot.json dr-datarails-server/HippiWeb/JWT_auth/tests/fixtures/dr_cli_endpoints.json
525
+ node -p "require('./dr-cli/package.json').version" > dr-datarails-server/HippiWeb/JWT_auth/tests/fixtures/dr_cli_endpoints.version
526
+ ```
527
+
528
+ A missing fixture makes the server test skip with a reason; a broken one is a hard
529
+ failure. Lifting the skip is DR-53487. Two caveats for that work: the server's
530
+ `contract_violations` currently resolves every record against the monolith URLconf and
531
+ does not filter by `service`, so it must filter to `service == "monolith"`, and it must
532
+ decide how to treat `command: "*"` records and `non_integer_params`.
533
+
480
534
  ### Adding a gated (destructive/sensitive) command
481
535
 
482
536
  Sensitive writes route through `twoStepConfirm` (`src/commands/_confirm.ts`),
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "@datarails-ai/dr-cli",
11
- version: "1.6.98",
11
+ version: "1.6.100",
12
12
  description: "Datarails CLI",
13
13
  type: "module",
14
14
  engines: {
@@ -29,6 +29,8 @@ var package_default = {
29
29
  build: "tsup",
30
30
  "gen:scopes": "tsx scripts/gen-scopes-map.mts",
31
31
  "check:scopes": "tsx scripts/gen-scopes-map.mts --check",
32
+ "gen:endpoints": "tsx scripts/gen-endpoints.mts",
33
+ "check:endpoints": "tsx scripts/gen-endpoints.mts --check",
32
34
  typecheck: "tsc -p tsconfig.build.json --noEmit",
33
35
  test: "vitest run",
34
36
  "test:watch": "vitest",
@@ -67,7 +69,7 @@ var package_default = {
67
69
  };
68
70
 
69
71
  // src/buildInfo.ts
70
- var BUILD_TIME = "2026-10-05T10:25:04.884Z";
72
+ var BUILD_TIME = "2026-10-05T12:55:55.787Z";
71
73
  function formatBuildTime(iso) {
72
74
  if (iso === "") return "";
73
75
  const d = new Date(iso);
@@ -5005,7 +5007,7 @@ async function runLogout(cfg) {
5005
5007
  const session2 = await cfg.store.getSession(key, meta.email);
5006
5008
  if (session2 === void 0) continue;
5007
5009
  try {
5008
- await revoker(host2, session2);
5010
+ writeNotes(cfg.out, await revoker(host2, session2));
5009
5011
  } catch {
5010
5012
  }
5011
5013
  await cfg.store.removeSession(key, meta.email);
@@ -5038,32 +5040,95 @@ async function runLogout(cfg) {
5038
5040
  }
5039
5041
  const host = resolve(cfg.serverKey).host;
5040
5042
  try {
5041
- await revoker(host, session);
5043
+ writeNotes(cfg.out, await revoker(host, session));
5042
5044
  } catch {
5043
5045
  }
5044
5046
  await cfg.store.removeSession(cfg.serverKey, email);
5045
5047
  cfg.out.write(`Logged out of ${cfg.serverKey} as ${email}.
5046
5048
  `);
5047
5049
  }
5050
+ function writeNotes(out, notes) {
5051
+ for (const line of notes ?? []) out.write(`${line}
5052
+ `);
5053
+ }
5054
+ var REVOKE_TIMEOUT_MS = 5e3;
5048
5055
  async function bestEffortLogout(host, session) {
5049
- if (host === "" || isCookielessSession(session)) return;
5056
+ if (host === "") return [];
5050
5057
  const base = host.replace(/\/+$/, "");
5051
- const controller = new AbortController();
5052
- const timeout = setTimeout(() => controller.abort(), 5e3);
5053
- try {
5054
- await fetch(`${base}/oauth/revoke/`, {
5058
+ if (usesApiKey(session)) {
5059
+ return [
5060
+ "Note: the API key is still valid server-side until it is revoked where it was issued; only the local copy was removed."
5061
+ ];
5062
+ }
5063
+ const refresh = session.jwtRefresh ?? "";
5064
+ const hasCookieSession = !isCookielessSession(session) && session.sessionId !== "" && session.csrfToken !== "";
5065
+ if (refresh === "" && !hasCookieSession) {
5066
+ return [
5067
+ `Warning: no refresh token is stored for this identity, so nothing was revoked on ${base}; only the local copy was removed.`
5068
+ ];
5069
+ }
5070
+ const [refreshFailure, sessionFailure] = await Promise.all([
5071
+ refresh === "" ? void 0 : revokeRefreshToken(base, refresh),
5072
+ hasCookieSession ? endCookieSession(base, session) : void 0
5073
+ ]);
5074
+ const notes = [];
5075
+ if (refreshFailure !== void 0) {
5076
+ notes.push(
5077
+ `Warning: could not revoke the refresh token on ${base} (${refreshFailure}); it stays valid server-side until it expires.`
5078
+ );
5079
+ }
5080
+ if (sessionFailure !== void 0) {
5081
+ notes.push(
5082
+ `Warning: could not end the browser session on ${base} (${sessionFailure}); it stays valid server-side until it expires.`
5083
+ );
5084
+ }
5085
+ return notes;
5086
+ }
5087
+ async function revokeRefreshToken(base, refresh) {
5088
+ return withTimeout(async (signal) => {
5089
+ const res = await fetch(`${base}/jwt/api/token/revoke/`, {
5090
+ method: "POST",
5091
+ headers: { "Content-Type": "application/json" },
5092
+ body: JSON.stringify({ refresh }),
5093
+ signal
5094
+ });
5095
+ await discardBody(res);
5096
+ return res.status === 204 ? void 0 : `HTTP ${res.status}`;
5097
+ });
5098
+ }
5099
+ async function endCookieSession(base, session) {
5100
+ return withTimeout(async (signal) => {
5101
+ const res = await fetch(`${base}/api/accounts/logout/`, {
5055
5102
  method: "POST",
5056
5103
  headers: {
5057
5104
  Cookie: `sessionid=${session.sessionId}; csrftoken=${session.csrfToken}`,
5058
- "X-CSRFToken": session.csrfToken
5105
+ "X-CSRFToken": session.csrfToken,
5106
+ Referer: `${base}/`
5059
5107
  },
5060
- signal: controller.signal
5108
+ redirect: "manual",
5109
+ signal
5061
5110
  });
5111
+ await discardBody(res);
5112
+ return res.status >= 200 && res.status < 400 ? void 0 : `HTTP ${res.status}`;
5113
+ });
5114
+ }
5115
+ async function withTimeout(run) {
5116
+ const controller = new AbortController();
5117
+ const timeout = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS);
5118
+ try {
5119
+ return await run(controller.signal);
5062
5120
  } catch {
5121
+ return controller.signal.aborted ? "timed out" : "network error";
5063
5122
  } finally {
5064
5123
  clearTimeout(timeout);
5065
5124
  }
5066
5125
  }
5126
+ async function discardBody(res) {
5127
+ try {
5128
+ await res.text();
5129
+ } catch {
5130
+ }
5131
+ }
5067
5132
 
5068
5133
  // src/commands/login.ts
5069
5134
  import { Option } from "commander";
@@ -5170,7 +5235,7 @@ function tryParseErrorCode(text2) {
5170
5235
  return typeof errVal === "string" ? errVal : "";
5171
5236
  }
5172
5237
  async function pollForToken(host, deviceCode, options = {}) {
5173
- return poll(host, PATHS.legacy.token, deviceCode, options, (text2) => {
5238
+ return poll(host, PATHS.legacy.token, deviceCode, options, {}, (text2) => {
5174
5239
  const tok = parseTokenResponse(text2);
5175
5240
  if (!tok.sessionId || !tok.csrfToken) {
5176
5241
  throw new Error("token response missing session_id or csrf_token");
@@ -5179,7 +5244,7 @@ async function pollForToken(host, deviceCode, options = {}) {
5179
5244
  });
5180
5245
  }
5181
5246
  async function pollForV2Token(host, deviceCode, options = {}) {
5182
- return poll(host, PATHS.v2.token, deviceCode, options, (text2) => {
5247
+ return poll(host, PATHS.v2.token, deviceCode, options, V2_TERMINAL_HINTS, (text2) => {
5183
5248
  const obj = parseObject(text2);
5184
5249
  const access = typeof obj.access === "string" ? obj.access : "";
5185
5250
  const refresh = typeof obj.refresh === "string" ? obj.refresh : "";
@@ -5189,7 +5254,11 @@ async function pollForV2Token(host, deviceCode, options = {}) {
5189
5254
  return { access, refresh };
5190
5255
  });
5191
5256
  }
5192
- async function poll(host, path11, deviceCode, options, onSuccess) {
5257
+ var V2_TERMINAL_HINTS = {
5258
+ access_denied: " (the login was refused, expired, or already used: run `dr login` again)",
5259
+ token_issuance_failed: " (the server could not issue the token: run `dr login` again to start a new login)"
5260
+ };
5261
+ async function poll(host, path11, deviceCode, options, terminalHints, onSuccess) {
5193
5262
  const interval = options.interval ?? 2e3;
5194
5263
  const maxWait = options.maxWait ?? 6e5;
5195
5264
  const now = options.now ?? Date.now;
@@ -5225,7 +5294,8 @@ async function poll(host, path11, deviceCode, options, onSuccess) {
5225
5294
  continue;
5226
5295
  }
5227
5296
  if (errCode) {
5228
- throw new Error(`token error: ${errCode}`);
5297
+ const hint = Object.hasOwn(terminalHints, errCode) ? terminalHints[errCode] : "";
5298
+ throw new Error(`token error: ${errCode}${hint}`);
5229
5299
  }
5230
5300
  throw new Error(`token HTTP ${res.status}: ${text2.trim()}`);
5231
5301
  }
@@ -25578,6 +25648,62 @@ function symbolForCurrencyCode(code) {
25578
25648
  return FX_CURRENCY_SYMBOLS.get(code.trim().toUpperCase()) ?? null;
25579
25649
  }
25580
25650
 
25651
+ // src/commands/_currencySymbol.ts
25652
+ var FIXED_SIGNS_RE = /"[$€£₪]"|[$€£₪]/g;
25653
+ var DEFAULT_NUMBER_FORMAT = "#,###";
25654
+ function stripFixedCurrencySigns(format) {
25655
+ return format ? format.replace(FIXED_SIGNS_RE, "") : format;
25656
+ }
25657
+ function withCurrencySign(format, sign) {
25658
+ const cleanSign = sign.replace(/"/g, "");
25659
+ const literal = `"${cleanSign}${/^[A-Za-z]+$/.test(cleanSign) ? " " : ""}"`;
25660
+ const base = format || DEFAULT_NUMBER_FORMAT;
25661
+ return base.split(";").map((section) => {
25662
+ if (section === "") return section;
25663
+ const leading = section.match(/^(\[[^\]]*\])*(?=.)/)?.[0] ?? "";
25664
+ const rest = section.slice(leading.length);
25665
+ const paren = rest.match(/^\((.*)\)$/);
25666
+ return paren ? `${leading}(${literal}${paren[1]})` : `${leading}${literal}${rest}`;
25667
+ }).join(";");
25668
+ }
25669
+ function parseWidgetOptions(raw) {
25670
+ if (raw === void 0 || raw === null || raw === "") return {};
25671
+ if (typeof raw === "object" && !Array.isArray(raw)) return raw;
25672
+ if (typeof raw !== "string") return null;
25673
+ try {
25674
+ const parsed2 = JSON.parse(raw);
25675
+ return typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2) ? parsed2 : null;
25676
+ } catch {
25677
+ return null;
25678
+ }
25679
+ }
25680
+ function readAutoCurrencySymbol(raw) {
25681
+ const opts = parseWidgetOptions(raw);
25682
+ if (opts === null) return null;
25683
+ return opts["autoCurrencySymbol"] === true;
25684
+ }
25685
+ function setAutoCurrencySymbol(raw, enabled) {
25686
+ const opts = parseWidgetOptions(raw);
25687
+ if (opts === null) {
25688
+ throw new Error("widget options is not a JSON object \u2014 refusing to rewrite it");
25689
+ }
25690
+ return JSON.stringify({ ...opts, autoCurrencySymbol: enabled });
25691
+ }
25692
+ function fixedSignOf(format) {
25693
+ if (typeof format !== "string") return null;
25694
+ const m = format.match(/^(?:\[[^\]]*\])*\(?"([^"]+)"/);
25695
+ const sign = m?.[1];
25696
+ return sign === void 0 ? null : sign.trim();
25697
+ }
25698
+ function describeCurrencySymbol(widget) {
25699
+ const auto = readAutoCurrencySymbol(widget["options"]);
25700
+ if (auto === true && widget["is_fx_aware"] === true) {
25701
+ return "Auto (follows the dashboard FX selector)";
25702
+ }
25703
+ const sign = fixedSignOf(widget["values_format"]);
25704
+ return sign === null ? "none" : `"${sign}" (fixed)`;
25705
+ }
25706
+
25581
25707
  // src/commands/fxConfig.ts
25582
25708
  function writeTable20(out, rows) {
25583
25709
  if (rows.length === 0) return;
@@ -26571,6 +26697,11 @@ async function runFxConvertWidgets(cfg) {
26571
26697
  "--force-sum has no meaning with --off (turning conversion off never touches the aggregator)"
26572
26698
  );
26573
26699
  }
26700
+ if (cfg.disable && !cfg.autoSymbol) {
26701
+ throw new Error(
26702
+ "--no-auto-symbol has no meaning with --off (turning conversion off always clears the Auto currency symbol)"
26703
+ );
26704
+ }
26574
26705
  let targetDashboards;
26575
26706
  if (cfg.all) {
26576
26707
  const dashboards = await list8(cfg.client, { signal: cfg.signal });
@@ -26619,6 +26750,7 @@ async function runFxConvertWidgets(cfg) {
26619
26750
  const v0 = vals !== null && vals.length === 1 ? vals[0] : null;
26620
26751
  const val = v0 !== null && typeof v0 === "object" ? v0 : null;
26621
26752
  const aggregator = val !== null ? String(val["aggregator"] ?? "").toUpperCase() : "";
26753
+ const autoSymbol = readAutoCurrencySymbol(raw["options"]);
26622
26754
  if (cfg.disable) {
26623
26755
  plan.push({
26624
26756
  dashboardId: target.id,
@@ -26627,7 +26759,8 @@ async function runFxConvertWidgets(cfg) {
26627
26759
  widgetName,
26628
26760
  aggregator,
26629
26761
  raw,
26630
- status: isAware ? "revert" : "already-off"
26762
+ status: isAware ? "revert" : "already-off",
26763
+ autoSymbol
26631
26764
  });
26632
26765
  continue;
26633
26766
  }
@@ -26659,12 +26792,25 @@ async function runFxConvertWidgets(cfg) {
26659
26792
  widgetName,
26660
26793
  aggregator,
26661
26794
  raw,
26662
- status: status2
26795
+ status: status2,
26796
+ autoSymbol
26663
26797
  });
26664
26798
  }
26665
26799
  }
26666
26800
  const subjectOf = (p) => `widget ${p.widgetId} "${p.widgetName}" (dashboard ${p.dashboardId} "${p.dashboardName}")`;
26667
- const describeEntry = (p) => planLine(p.status, subjectOf(p), p.aggregator);
26801
+ const symbolNote = (p) => {
26802
+ if (p.status === "convert" || p.status === "convert-sum") {
26803
+ return cfg.autoSymbol ? " (+ Auto currency symbol)" : "";
26804
+ }
26805
+ if (p.status === "already" && p.autoSymbol === false) {
26806
+ return ` (fixed currency symbol \u2014 \`dr widgets set-currency-symbol ${p.widgetId} -d ${p.dashboardId} --auto\` to follow the FX selector)`;
26807
+ }
26808
+ if (p.status === "revert" && p.autoSymbol === true) {
26809
+ return " (Auto currency symbol \u2192 none)";
26810
+ }
26811
+ return "";
26812
+ };
26813
+ const describeEntry = (p) => planLine(p.status, subjectOf(p), p.aggregator) + symbolNote(p);
26668
26814
  const targets = plan.filter((p) => isActionable(p.status, cfg.disable));
26669
26815
  const needsRole = plan.filter((p) => p.status === "needs-fx-role").length;
26670
26816
  const summary = () => {
@@ -26680,6 +26826,7 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26680
26826
  writeJSONIndented(cfg.out, {
26681
26827
  dry_run: true,
26682
26828
  direction: cfg.disable ? "off" : "on",
26829
+ auto_symbol: cfg.disable ? false : cfg.autoSymbol,
26683
26830
  scanned,
26684
26831
  not_eligible: notEligible,
26685
26832
  plan: plan.map((p) => ({
@@ -26688,7 +26835,8 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26688
26835
  widget_id: p.widgetId,
26689
26836
  name: p.widgetName,
26690
26837
  aggregator: p.aggregator,
26691
- status: p.status
26838
+ status: p.status,
26839
+ auto_currency_symbol: p.autoSymbol
26692
26840
  })),
26693
26841
  unreadable
26694
26842
  });
@@ -26728,10 +26876,19 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26728
26876
  const v0 = vals[0];
26729
26877
  body["vals"] = [{ ...v0, aggregator: "SUM" }];
26730
26878
  }
26879
+ let wantAuto = null;
26880
+ if (want && cfg.autoSymbol) wantAuto = true;
26881
+ else if (!want && p.autoSymbol === true) wantAuto = false;
26882
+ if (wantAuto !== null) body["options"] = setAutoCurrencySymbol(p.raw["options"], wantAuto);
26731
26883
  const res = await update6(cfg.client, p.dashboardId, p.widgetId, body, {
26732
26884
  signal: cfg.signal
26733
26885
  });
26734
26886
  assertFxAwareEcho(res["is_fx_aware"], want, "widget");
26887
+ if (wantAuto !== null && readAutoCurrencySymbol(res["options"]) !== wantAuto) {
26888
+ throw new Error(
26889
+ `server did not keep options.autoCurrencySymbol=${wantAuto} (the Auto currency symbol)`
26890
+ );
26891
+ }
26735
26892
  results.push({
26736
26893
  dashboard_id: p.dashboardId,
26737
26894
  widget_id: p.widgetId,
@@ -26740,8 +26897,10 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26740
26897
  });
26741
26898
  if (!cfg.asJson) {
26742
26899
  for (const line of unreadableLines(unreadable, "dashboard")) cfg.out.write(line + "\n");
26743
- cfg.out.write(`\u2713 ${describeConvertStatus(p.status, subjectOf(p), p.aggregator)}
26744
- `);
26900
+ cfg.out.write(
26901
+ `\u2713 ${describeConvertStatus(p.status, subjectOf(p), p.aggregator)}${symbolNote(p)}
26902
+ `
26903
+ );
26745
26904
  }
26746
26905
  } catch (err) {
26747
26906
  if (cfg.signal.aborted) throw err;
@@ -27080,11 +27239,14 @@ function registerFxConfigCommands(fx) {
27080
27239
  addServerFlag(convertFunctionsCmd);
27081
27240
  addAccountFlag(convertFunctionsCmd);
27082
27241
  const convertWidgetsCmd = fx.command("convert-widgets").description(
27083
- "Enable currency conversion (is_fx_aware=true) on every eligible dashboard widget (PUT /api/dashboards/<d>/widgets/<w>, round-tripping the widget body verbatim). Eligible = the value section holds exactly one field, that field is Monetary, and it carries an fx_role. Widgets whose aggregator is not SUM are skipped unless --force-sum switches them to SUM. The server has the final say on the flag \u2014 a widget it deems ineligible is reported as failed. Use --off to sweep the other way and turn conversion off \u2014 a rollback, which ignores eligibility and the aggregator and acts on the stored flag alone. Dry-run by default \u2014 prints the plan; re-run with --confirm to apply. Scope with --dashboard <id> or sweep --all."
27242
+ "Enable currency conversion (is_fx_aware=true) on every eligible dashboard widget (PUT /api/dashboards/<d>/widgets/<w>, round-tripping the widget body verbatim). Eligible = the value section holds exactly one field, that field is Monetary, and it carries an fx_role. Widgets whose aggregator is not SUM are skipped unless --force-sum switches them to SUM. The server has the final say on the flag \u2014 a widget it deems ineligible is reported as failed. Each converted widget also switches to the Auto currency symbol (options.autoCurrencySymbol=true \u2014 the sign of the currency selected on the dashboard), as the widget editor does; --no-auto-symbol keeps the stored number format instead. Use --off to sweep the other way and turn conversion off \u2014 a rollback, which ignores eligibility and the aggregator and acts on the stored flag alone (and clears Auto, which only renders on a currency-aware widget). Dry-run by default \u2014 prints the plan; re-run with --confirm to apply. Scope with --dashboard <id> or sweep --all."
27084
27243
  ).option("--dashboard <id>", "Convert widgets on this dashboard only").option("--all", "Convert widgets on every dashboard", false).option(
27085
27244
  "--force-sum",
27086
27245
  "Also convert non-SUM widgets by switching their aggregator to SUM",
27087
27246
  false
27247
+ ).option(
27248
+ "--no-auto-symbol",
27249
+ "Keep each converted widget's stored number format (its fixed currency sign, if any) instead of switching it to the Auto currency symbol"
27088
27250
  ).option(
27089
27251
  "--off",
27090
27252
  "Reverse direction: turn currency conversion OFF on every currency-aware widget in scope",
@@ -27100,6 +27262,7 @@ function registerFxConfigCommands(fx) {
27100
27262
  all: opts.all === true,
27101
27263
  forceSum: opts.forceSum === true,
27102
27264
  disable: opts.off === true,
27265
+ autoSymbol: opts.autoSymbol !== false,
27103
27266
  confirm: opts.confirm === true,
27104
27267
  asJson: opts.json === true,
27105
27268
  signal: setup.signal,
@@ -41231,6 +41394,10 @@ async function runWidgetsGet(cfg) {
41231
41394
  cfg.out.write(`chart_type: ${found["chart_type"] ?? ""}
41232
41395
  `);
41233
41396
  cfg.out.write(`template_id: ${found["template_id"] ?? "(none)"}
41397
+ `);
41398
+ cfg.out.write(`fx_aware: ${found["is_fx_aware"] === true ? "yes" : "no"}
41399
+ `);
41400
+ cfg.out.write(`fx_symbol: ${describeCurrencySymbol(found)}
41234
41401
  `);
41235
41402
  cfg.out.write("\n(use --json for the full widget body)\n");
41236
41403
  }
@@ -41309,6 +41476,67 @@ async function runWidgetsUpdate(cfg) {
41309
41476
  });
41310
41477
  printWidgetMutation(cfg.out, updated, "Updated", cfg.asJson);
41311
41478
  }
41479
+ async function runWidgetsSetCurrencySymbol(cfg) {
41480
+ if (cfg.dashboardId <= 0) throw new Error("--dashboard is required");
41481
+ if (cfg.id <= 0) throw new Error("widget id must be positive");
41482
+ const sign = cfg.mode === "sign" ? (cfg.sign ?? "").trim() : "";
41483
+ if (cfg.mode === "sign" && (sign === "" || sign.length > 5 || sign.includes('"'))) {
41484
+ throw new Error(
41485
+ "--sign needs a currency sign of 1\u20135 characters without quotes (e.g. $, \u20AC, \xA3, \u20AA or a code such as CHF)"
41486
+ );
41487
+ }
41488
+ const all = await listRaw3(cfg.client, cfg.dashboardId, { signal: cfg.signal });
41489
+ const found = all.find((w) => w["id"] === cfg.id);
41490
+ if (found === void 0) {
41491
+ throw new Error(`widget ${cfg.id} not found on dashboard ${cfg.dashboardId}`);
41492
+ }
41493
+ if (cfg.mode === "auto" && found["is_fx_aware"] !== true) {
41494
+ throw new Error(
41495
+ `widget ${cfg.id} is not currency-aware \u2014 the Auto symbol only renders on a widget with currency conversion on; run dr fx convert-widgets --dashboard ${cfg.dashboardId} first`
41496
+ );
41497
+ }
41498
+ const wantAuto = cfg.mode === "auto";
41499
+ const current = typeof found["values_format"] === "string" ? found["values_format"] : null;
41500
+ const body = {
41501
+ ...found,
41502
+ options: setAutoCurrencySymbol(found["options"], wantAuto)
41503
+ };
41504
+ if (cfg.mode === "none") {
41505
+ body["values_format"] = stripFixedCurrencySigns(current) || null;
41506
+ } else if (cfg.mode === "sign") {
41507
+ body["values_format"] = withCurrencySign(stripFixedCurrencySigns(current), sign);
41508
+ }
41509
+ const res = await update6(cfg.client, cfg.dashboardId, cfg.id, body, {
41510
+ signal: cfg.signal
41511
+ });
41512
+ if (readAutoCurrencySymbol(res["options"]) !== wantAuto) {
41513
+ throw new Error(
41514
+ `server did not keep options.autoCurrencySymbol=${wantAuto} (the Auto currency symbol)`
41515
+ );
41516
+ }
41517
+ if (cfg.mode !== "auto" && (res["values_format"] ?? null) !== body["values_format"]) {
41518
+ throw new Error(
41519
+ `server did not keep values_format ${JSON.stringify(body["values_format"])} (came back ${JSON.stringify(res["values_format"] ?? null)})`
41520
+ );
41521
+ }
41522
+ if (cfg.asJson) {
41523
+ writeJSONIndented(cfg.out, {
41524
+ id: res["id"] ?? cfg.id,
41525
+ name: res["name"] ?? found["name"] ?? null,
41526
+ is_fx_aware: res["is_fx_aware"] === true,
41527
+ auto_currency_symbol: wantAuto,
41528
+ values_format: res["values_format"] ?? null,
41529
+ currency_symbol: describeCurrencySymbol(res)
41530
+ });
41531
+ return;
41532
+ }
41533
+ cfg.out.write(
41534
+ `widget ${cfg.id} "${String(found["name"] ?? "")}": currency symbol \u2192 ${describeCurrencySymbol(res)}
41535
+ `
41536
+ );
41537
+ cfg.out.write(`values_format: ${res["values_format"] ?? "(none)"}
41538
+ `);
41539
+ }
41312
41540
  async function runWidgetsDelete(cfg) {
41313
41541
  if (cfg.dashboardId <= 0) throw new Error("--dashboard is required");
41314
41542
  if (cfg.id <= 0) throw new Error("widget id must be positive");
@@ -41660,6 +41888,36 @@ function registerWidgetsCommand(root) {
41660
41888
  });
41661
41889
  addServerFlag(updateCmd);
41662
41890
  addAccountFlag(updateCmd);
41891
+ const symbolCmd = widgets.command("set-currency-symbol").description(
41892
+ "Set a widget's currency symbol the way the widget editor's symbol dropdown does. --auto follows the dashboard FX selector (options.autoCurrencySymbol=true; offered on currency-aware widgets only \u2014 see fx convert-widgets); --none shows no sign (clears Auto and strips the fixed $ \u20AC \xA3 \u20AA from the number format); --sign <S> pins one fixed sign as the format's leading literal (clears Auto). Read-modify-write of the widget body (PUT /api/dashboards/<d>/widgets/<w>); the echo is verified."
41893
+ ).argument("<id>", "widget id (positive integer)").requiredOption("-d, --dashboard <id>", "dashboard id (positive integer)").option("--auto", "Follow the dashboard FX selector (currency-aware widgets only)", false).option("--none", "No currency sign", false).option("--sign <S>", "One fixed sign: $ \u20AC \xA3 \u20AA, or a code such as CHF").option("--json", "Output a structured JSON result instead of a summary", false).action(
41894
+ async (idStr, opts) => {
41895
+ const id = parseWidgetIdArg(idStr);
41896
+ const dashboardId = parseDashboardFlag(opts.dashboard);
41897
+ const picked = [opts.auto === true, opts.none === true, opts.sign !== void 0].filter(
41898
+ Boolean
41899
+ ).length;
41900
+ if (picked !== 1) throw new Error("pass exactly one of --auto, --none or --sign <S>");
41901
+ const mode = opts.auto === true ? "auto" : opts.none === true ? "none" : "sign";
41902
+ const setup = await setupCall(opts.server, opts.account);
41903
+ try {
41904
+ await runWidgetsSetCurrencySymbol({
41905
+ client: setup.client,
41906
+ dashboardId,
41907
+ id,
41908
+ mode,
41909
+ sign: opts.sign,
41910
+ asJson: opts.json === true,
41911
+ signal: setup.signal,
41912
+ out: process.stdout
41913
+ });
41914
+ } finally {
41915
+ setup.cleanup();
41916
+ }
41917
+ }
41918
+ );
41919
+ addServerFlag(symbolCmd);
41920
+ addAccountFlag(symbolCmd);
41663
41921
  const deleteCmd = widgets.command("delete").description("Delete a widget (two-step confirm)").argument("<id>", "widget id (positive integer)").requiredOption("-d, --dashboard <id>", "dashboard id (positive integer)").option("--confirm-token <tok>", "Token from a prior plan run \u2014 required to actually delete").action(async (idStr, opts) => {
41664
41922
  const id = parseWidgetIdArg(idStr);
41665
41923
  const dashboardId = parseDashboardFlag(opts.dashboard);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.98",
3
+ "cliVersion": "1.6.100",
4
4
  "_contract": "Resolve a command path through `aliases` (canonical prefix + alias segment -> canonical path) before any lookup. A path listed in `pendingCommands`, or whose first segment is a key of `pending`, is UNDECIDED, not unrestricted: fail closed or skip it, and never read a path's absence from `commands` as 'no requirement'. An entry carrying `denied` is UNAVAILABLE to any AGENT-SCOPED credential — one whose `scopes` claim carries at least one `resource:action` entry; a claim that is absent, empty, capability-only or wholly unparseable is UNRESTRICTED, and this mark does not apply to it — whatever else that entry says: it is not a ceiling and it is not always-available, its `denied.reason` says why and `denied.until` names the ticket that lifts it, and the absence of `requires` on it must NEVER be read as 'no requirement'. Only an entry in `commands` with an empty `requires` means always-available.",
5
5
  "commands": {
6
6
  "login": {
@@ -2251,6 +2251,14 @@
2251
2251
  }
2252
2252
  ]
2253
2253
  },
2254
+ "widgets set-currency-symbol": {
2255
+ "requires": [
2256
+ {
2257
+ "resource": "dashboards",
2258
+ "action": "write"
2259
+ }
2260
+ ]
2261
+ },
2254
2262
  "widgets delete": {
2255
2263
  "requires": [
2256
2264
  {
@@ -248,7 +248,10 @@ area's gotchas.
248
248
  - `dashboards push <id> --to <orgs> [--override]` — copy a dashboard into other
249
249
  organizations (two-step confirm). Destinations must come from
250
250
  `organizations push-targets`.
251
- - `widgets list|get|data|create|update|delete|chart|kpi|pie|table|gauge|waterfall|published-item|text` (alias `widget`)
251
+ - `widgets list|get|data|create|update|set-currency-symbol|delete|chart|kpi|pie|table|gauge|waterfall|published-item|text` (alias `widget`)
252
+ — `widgets set-currency-symbol <id> -d <dash> --auto|--none|--sign <S>` is the
253
+ widget editor's currency-symbol dropdown: **Auto** (the sign of the currency
254
+ selected on the dashboard; currency-aware widgets only), none, or one fixed sign.
252
255
  — `widgets data` applies the dashboard's saved filter bar + date picker by
253
256
  default so results match the product UI; `--no-dashboard-context` computes
254
257
  from the widget's own settings only.
@@ -439,7 +442,7 @@ area's gotchas.
439
442
  - `devices list|get|logs|request-logs|received-logs|download-logs|commands|command-delete`
440
443
  - `features list`, `features groups list|get`, `features orgs list|add|remove`
441
444
  - `semantictables list|get|data|distinct` (alias `semtables`), `fx rates`
442
- - `fx set-posting|set-reporting` (Monetary field FX roles; `--all-tables` sweeps the org), `fx default-currency` (org-wide default, Super Admin), `fx add-currency` (org custom currency, e.g. BUSD), `fx convert-functions|convert-widgets` (bulk currency-aware sweeps, `--off` to roll back)
445
+ - `fx set-posting|set-reporting` (Monetary field FX roles; `--all-tables` sweeps the org), `fx default-currency` (org-wide default, Super Admin), `fx add-currency` (org custom currency, e.g. BUSD), `fx convert-functions|convert-widgets` (bulk currency-aware sweeps, `--off` to roll back; `convert-widgets` also switches each converted widget to the Auto currency symbol unless `--no-auto-symbol`)
443
446
  - `kb search|get|categories|sections|articles-in-section|recent|attachments|article-images`
444
447
  - `mcp register|unregister|list|serve`, `skill list|print|install|init-project`
445
448
 
@@ -755,11 +755,38 @@ name.
755
755
  **and carries an fx_role**. Non-SUM functions are skipped unless `--force-sum`
756
756
  switches them. **Dry-run by default** — always run once without `--confirm` and
757
757
  read the plan.
758
- - `fx convert-widgets (--dashboard <id> | --all) [--force-sum] [--off] [--confirm]` —
758
+ - `fx convert-widgets (--dashboard <id> | --all) [--force-sum] [--no-auto-symbol] [--off] [--confirm]` —
759
759
  same sweep for dashboard widgets (eligible = exactly one value field, Monetary,
760
760
  with an fx_role). The server has the final say per widget; an echo that doesn't
761
761
  confirm the flag is reported as a failure, not silently accepted. Dry-run by
762
- default.
762
+ default. Each converted widget is also switched to the **Auto currency symbol**
763
+ (see below) — the plan line reads `enable currency conversion (+ Auto currency
764
+ symbol)`; `--no-auto-symbol` keeps the widget's stored number format instead.
765
+
766
+ **The currency symbol travels with the flag (DR-53702).** The widget editor's number
767
+ format offers three symbol states: a *fixed* sign (`"$"#,###` — the sign is the
768
+ format's leading literal), *none*, and — on currency-aware widgets only — **Auto**,
769
+ which renders the sign of the currency selected in the dashboard's FX selector (its
770
+ ISO code when a custom currency has no symbol). Auto is not in the format string; it is
771
+ `autoCurrencySymbol: true` inside the widget's `options` JSON blob, and the renderer
772
+ applies it only while `is_fx_aware` is true, stripping any fixed `$ € £ ₪` the format
773
+ still carries. The editor switches a widget to Auto when it becomes currency-aware and
774
+ back to none when it stops being so, and the CLI mirrors that:
775
+
776
+ - `fx convert-widgets` sets Auto on every widget it converts (the format itself is left
777
+ alone — the renderer ignores its fixed sign while Auto applies). `--off` clears Auto
778
+ on widgets that had it. An already-aware widget that still shows a fixed sign is
779
+ **not** changed by the sweep; the plan names it and points at the per-widget command.
780
+ - `widgets set-currency-symbol <id> -d <dashboard> (--auto | --none | --sign <S>)` is
781
+ the dropdown for one widget: `--auto` (refused on a widget that is not
782
+ currency-aware — convert it first), `--none` (clears Auto and strips the fixed
783
+ `$ € £ ₪` from every section of the format), `--sign <S>` (clears Auto and writes `S`
784
+ as the leading literal of every section, with the renderer's placement rules — after
785
+ `[Red]`-style tokens, inside parentheses, a trailing space after a letter code such as
786
+ `CHF`). Read-modify-write of the server's body with the echo verified; `--json` for
787
+ the resulting state.
788
+ - `widgets get <id> -d <dashboard>` prints `fx_aware:` and `fx_symbol:` (`Auto …`,
789
+ `none`, or `"$" (fixed)`) so the state is visible without reading the body.
763
790
 
764
791
  **Eligibility is stricter server-side than in the web app.** `FxEligibilityService`
765
792
  requires the value field to be Monetary AND to have an `fx_role`; the serializer
@@ -51,6 +51,13 @@ notifications. Confirm with `dashboards list --account <destination>`.
51
51
  same as scheduled report emails.
52
52
  - `widgets create --file` (power mode), `widgets update <id> --file`,
53
53
  `widgets delete <id>` (two-step confirm).
54
+ - `widgets set-currency-symbol <id> -d <dashboard> --auto|--none|--sign <S>` — the
55
+ widget editor's currency-symbol dropdown from the CLI. `--auto` follows the
56
+ dashboard FX selector (currency-aware widgets only — `fx convert-widgets` first);
57
+ `--none` clears Auto and strips the fixed `$ € £ ₪` from the number format;
58
+ `--sign <S>` pins one fixed sign. `widgets get` shows the current state as
59
+ `fx_symbol:`. Full semantics under **FX configuration** in
60
+ [admin.md](admin.md).
54
61
  - Flag-driven builders:
55
62
  - `widgets chart --type column|bar|line|area` + modifiers `--stacked`
56
63
  `--percent` `--smooth`.
@@ -246,6 +246,13 @@ Rules while executing:
246
246
 
247
247
  - **Never pass `--force-sum` without saying so in the plan.** It changes a widget's
248
248
  aggregator to SUM, which changes what the customer sees.
249
+ - **The widget sweep also switches each converted widget to the Auto currency
250
+ symbol** (the sign of the currency selected on the dashboard), exactly as the widget
251
+ editor does — the plan line says `(+ Auto currency symbol)`. Say so in the plan. If
252
+ the customer wants converted widgets to keep their fixed `$`, pass
253
+ `--no-auto-symbol`. Widgets that were already currency-aware keep their fixed sign;
254
+ the plan lists them with the `dr widgets set-currency-symbol <id> -d <dash> --auto`
255
+ command to run per widget if the customer wants them to follow the selector too.
249
256
  - **Never use `--allow-custom` or `--allow-unlisted` to get past a validation error.**
250
257
  They exist for a code you know is coming, not to silence a refusal.
251
258
  - **Re-runs are safe.** Every command is idempotent — an already-configured field
@@ -307,7 +314,9 @@ These stay manual, and omitting them from the report leaves the job half-done:
307
314
 
308
315
  - **Widgets / functions:** `dr fx convert-widgets --all --off --confirm` and
309
316
  `dr fx convert-functions --off --confirm`. Acts on the stored flag alone, so it also
310
- clears flags left on entities that no longer qualify.
317
+ clears flags left on entities that no longer qualify. `--off` also clears the Auto
318
+ currency symbol on widgets that had it; a widget's fixed sign, if it had one before
319
+ conversion, is still in its number format and shows again.
311
320
  - **A widget body:** `dr widgets update <id> -d <dashboard> --file before-widget-<id>.json`.
312
321
  - **Org default:** `dr fx default-currency --none --confirm` (back to posting currency)
313
322
  or set the previous code.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-cli",
3
- "version": "1.6.98",
3
+ "version": "1.6.100",
4
4
  "description": "Datarails CLI",
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,6 +48,8 @@
48
48
  "build": "tsup",
49
49
  "gen:scopes": "tsx scripts/gen-scopes-map.mts",
50
50
  "check:scopes": "tsx scripts/gen-scopes-map.mts --check",
51
+ "gen:endpoints": "tsx scripts/gen-endpoints.mts",
52
+ "check:endpoints": "tsx scripts/gen-endpoints.mts --check",
51
53
  "typecheck": "tsc -p tsconfig.build.json --noEmit",
52
54
  "test": "vitest run",
53
55
  "test:watch": "vitest",