@datarails-ai/dr-cli 1.6.97 → 1.6.99

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
@@ -230,6 +230,20 @@ under one identity and run as another.
230
230
  { "apiKey": "<key>", "server": "DEV", "email": "svc@datarails.com" }
231
231
  ```
232
232
 
233
+ The key may instead live in an environment variable, with the file carrying
234
+ only the variable's **name** — the shape for CI (the key stays in the runner's
235
+ secret env, never on disk) and for agent sandboxes (where the variable's
236
+ in-sandbox value is an opaque vault placeholder substituted at egress, passed
237
+ verbatim and never validated):
238
+
239
+ ```json
240
+ { "apiKeyEnvVar": "DR_API_KEY", "server": "DEV", "email": "svc@datarails.com" }
241
+ ```
242
+
243
+ `apiKey` and `apiKeyEnvVar` are mutually exclusive — a file carrying both is
244
+ refused rather than guessed about. An unset variable fails the command with an
245
+ error naming it; the stored file is never modified by that failure.
246
+
233
247
  `email` names the stored identity — what `--account` selects and what the
234
248
  keychain entry is keyed by. It need not match the principal the key is bound to;
235
249
  the token the exchange mints is what every request authenticates as, and
@@ -463,6 +477,60 @@ plugins tree (manifests, frontmatter, versions, the facts file, `CONTRIBUTING.md
463
477
  `node --test plugins/dr-cli/hooks/tests/auto-update.test.js` runs the hook's
464
478
  contract suite.
465
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
+
466
534
  ### Adding a gated (destructive/sensitive) command
467
535
 
468
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.97",
11
+ version: "1.6.99",
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",
@@ -38,7 +40,7 @@ var package_default = {
38
40
  postinstall: "node scripts/postinstall.mjs"
39
41
  },
40
42
  dependencies: {
41
- "@datarails-ai/dr-appspace-cli": "0.10.20",
43
+ "@datarails-ai/dr-appspace-cli": "0.10.21",
42
44
  "@modelcontextprotocol/sdk": "^1.29.0",
43
45
  "@napi-rs/keyring": "^1.3.0",
44
46
  commander: "^12.1.0",
@@ -67,7 +69,7 @@ var package_default = {
67
69
  };
68
70
 
69
71
  // src/buildInfo.ts
70
- var BUILD_TIME = "2026-10-05T07:44:13.912Z";
72
+ var BUILD_TIME = "2026-10-05T12:03:05.855Z";
71
73
  function formatBuildTime(iso) {
72
74
  if (iso === "") return "";
73
75
  const d = new Date(iso);
@@ -151,14 +153,17 @@ function parseApiKeyExpiry(value) {
151
153
  function hasApiKey(s) {
152
154
  return s.apiKey !== void 0 && s.apiKey !== "";
153
155
  }
156
+ function hasApiKeyEnvVar(s) {
157
+ return s.apiKeyEnvVar !== void 0 && s.apiKeyEnvVar !== "";
158
+ }
154
159
  function isImpersonatedSession(s) {
155
160
  return s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "";
156
161
  }
157
162
  function isCookielessSession(s) {
158
- return isImpersonatedSession(s) || s.cookieless === true || hasApiKey(s);
163
+ return isImpersonatedSession(s) || s.cookieless === true || hasApiKey(s) || hasApiKeyEnvVar(s);
159
164
  }
160
165
  function usesApiKey(s) {
161
- return hasApiKey(s) && !isImpersonatedSession(s);
166
+ return (hasApiKey(s) || hasApiKeyEnvVar(s)) && !isImpersonatedSession(s);
162
167
  }
163
168
  var KeychainError = class extends Error {
164
169
  constructor(message, options) {
@@ -294,6 +299,16 @@ var credentialsFileSchema = z.object({
294
299
  // A user-bound API key, exchanged for `access` on demand. A file may hold
295
300
  // only this; `access` then appears after the first exchange.
296
301
  apiKey: z.string().min(1).optional(),
302
+ // DR-52657: the NAME of an environment variable holding the API key, for
303
+ // provisioners that must not put the key on disk — an agent sandbox, where
304
+ // the variable's in-sandbox value is an opaque vault placeholder
305
+ // substituted at egress, or a CI runner keeping the key in its secret env.
306
+ // The value is resolved at exchange time and passed verbatim; dr-cli never
307
+ // inspects, validates, or logs it. Mutually exclusive with `apiKey` (a
308
+ // refinement below): a file carrying both is ambiguous about which
309
+ // credential authenticates it, and silently preferring either one turns a
310
+ // provisioning mistake into commands running as the wrong key.
311
+ apiKeyEnvVar: z.string().min(1).optional(),
297
312
  // Carried verbatim, judged by `parseApiKeyExpiry` where it is read. Not
298
313
  // validated here, and not `.catch(undefined)` either: a rotation rewrites
299
314
  // the whole file from the parsed object, so a value this schema dropped
@@ -332,13 +347,19 @@ var credentialsFileSchema = z.object({
332
347
  }).passthrough().refine((c) => c.sessionId === void 0 === (c.csrfToken === void 0), {
333
348
  message: "sessionId and csrfToken must be provided together",
334
349
  path: ["sessionId"]
335
- }).refine((c) => c.access !== void 0 || c.apiKey !== void 0, {
336
- message: "one of access or apiKey is required",
350
+ }).refine((c) => !(c.apiKey !== void 0 && c.apiKeyEnvVar !== void 0), {
351
+ message: "apiKey and apiKeyEnvVar are mutually exclusive",
352
+ path: ["apiKeyEnvVar"]
353
+ }).refine((c) => c.access !== void 0 || c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0, {
354
+ message: "one of access, apiKey or apiKeyEnvVar is required",
337
355
  path: ["access"]
338
- }).refine((c) => c.apiKey !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0, {
339
- message: "refresh is required unless an apiKey or a session cookie is present",
340
- path: ["refresh"]
341
- });
356
+ }).refine(
357
+ (c) => c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0,
358
+ {
359
+ message: "refresh is required unless an apiKey, apiKeyEnvVar or a session cookie is present",
360
+ path: ["refresh"]
361
+ }
362
+ );
342
363
  function credentialsFilePath() {
343
364
  const override = process.env[CREDENTIALS_FILE_ENV];
344
365
  return override !== void 0 && override !== "" ? override : DEFAULT_CREDENTIALS_FILE;
@@ -801,7 +822,13 @@ async function exchangeApiKey(host, apiKey, options) {
801
822
  if (key === "") {
802
823
  throw new Error("api-key exchange: the stored API key is empty");
803
824
  }
804
- if (!/^[\x21-\x7e]+$/.test(key)) {
825
+ if (options?.opaque === true) {
826
+ if (/[\r\n\0]/.test(key) || !/^[\x00-\xff]+$/.test(key)) {
827
+ throw new Error(
828
+ "api-key exchange: the environment variable named by apiKeyEnvVar holds a value that cannot travel in an HTTP header (a line break, NUL, or a character above U+00FF)"
829
+ );
830
+ }
831
+ } else if (!/^[\x21-\x7e]+$/.test(key)) {
805
832
  throw new Error(
806
833
  "api-key exchange: dr only accepts an API key of printable ASCII with no spaces; the stored one contains whitespace, control characters, or non-ASCII characters"
807
834
  );
@@ -965,10 +992,31 @@ function mintedByApiKey(token) {
965
992
  function apiKeyFingerprint(apiKey) {
966
993
  return createHash("sha256").update(apiKey.trim()).digest("hex").slice(0, 32);
967
994
  }
995
+ function resolveApiKeyMaterial(session) {
996
+ if (session.apiKey !== void 0 && session.apiKey !== "") {
997
+ return { key: session.apiKey, fromEnv: false };
998
+ }
999
+ const name = session.apiKeyEnvVar;
1000
+ if (name === void 0 || name === "") {
1001
+ throw new Error("api-key exchange: the session carries no API key");
1002
+ }
1003
+ const value = process.env[name];
1004
+ if (value === void 0 || value === "") {
1005
+ throw new Error(
1006
+ `api-key exchange: the credential file names environment variable ${name} as the API-key carrier, but it is unset or empty in this environment`
1007
+ );
1008
+ }
1009
+ return { key: value, fromEnv: true };
1010
+ }
968
1011
  function mintedByStoredApiKey(session) {
969
1012
  if (!mintedByApiKey(session.jwtAccess)) return false;
970
- if (session.apiKey === void 0 || session.apiKey === "") return false;
971
- return session.jwtApiKeyFingerprint === apiKeyFingerprint(session.apiKey);
1013
+ let material;
1014
+ try {
1015
+ material = resolveApiKeyMaterial(session);
1016
+ } catch {
1017
+ return false;
1018
+ }
1019
+ return session.jwtApiKeyFingerprint === apiKeyFingerprint(material.key);
972
1020
  }
973
1021
  function apiKeyStaleBuffer(session, identity2, fallbackMs) {
974
1022
  if (session.jwtAccess === void 0 || session.jwtAccess === "") return fallbackMs;
@@ -995,12 +1043,13 @@ function ttlOf(token) {
995
1043
  function exchangeForSession(store, serverKey, email, session, host, opts) {
996
1044
  const identity2 = `${serverKey}:${email}`;
997
1045
  if (opts.signal?.aborted === true) return Promise.reject(opts.signal.reason);
998
- const key = JSON.stringify([
999
- identity2,
1000
- host,
1001
- opts.force,
1002
- apiKeyFingerprint(session.apiKey)
1003
- ]);
1046
+ let material;
1047
+ try {
1048
+ material = resolveApiKeyMaterial(session);
1049
+ } catch (err) {
1050
+ throw new SessionExpiredError(`${identity2} (${msgOf3(err)})`);
1051
+ }
1052
+ const key = JSON.stringify([identity2, host, opts.force, apiKeyFingerprint(material.key)]);
1004
1053
  const pending = inFlightExchanges.get(key);
1005
1054
  if (pending !== void 0) return joinExchange(pending, opts.signal);
1006
1055
  const controller = new AbortController();
@@ -1015,7 +1064,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1015
1064
  for (; ; ) {
1016
1065
  let tok;
1017
1066
  try {
1018
- tok = await exchangeApiKey(host, session.apiKey, { signal });
1067
+ tok = await exchangeApiKey(host, material.key, { signal, opaque: material.fromEnv });
1019
1068
  } catch (err) {
1020
1069
  const cachedIsUsable = !opts.force && mintedByStoredApiKey(session) && !jwtExpiringSoon(session, 0);
1021
1070
  if (!(err instanceof ApiKeyExchangeError)) {
@@ -1054,7 +1103,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1054
1103
  // the token did not come from. Read back by `mintedByStoredApiKey` on
1055
1104
  // every later command to tell this token apart from one the previous
1056
1105
  // key minted.
1057
- jwtApiKeyFingerprint: apiKeyFingerprint(session.apiKey),
1106
+ jwtApiKeyFingerprint: apiKeyFingerprint(material.key),
1058
1107
  // Dropped, not carried: the exchange mints no refresh token, and the
1059
1108
  // refresh rung is closed to a keyed identity by contract. Clearing it
1060
1109
  // is what lets "keyed, and still holding a refresh token" mean "these
@@ -1065,7 +1114,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1065
1114
  };
1066
1115
  try {
1067
1116
  const current = await store.getSession(serverKey, email);
1068
- if (current === void 0 || current.apiKey !== session.apiKey) {
1117
+ if (current === void 0 || current.apiKey !== session.apiKey || current.apiKeyEnvVar !== session.apiKeyEnvVar) {
1069
1118
  return next;
1070
1119
  }
1071
1120
  await store.saveSession(serverKey, next);
@@ -1383,7 +1432,8 @@ var KeyringStore = class _KeyringStore {
1383
1432
  const impersonated = impersonatorEmail !== "";
1384
1433
  const cookielessFlag = parsed2.cookieless === true;
1385
1434
  const apiKey = await this.backend.get(SERVICE, `${sessionAccount}:apikey`);
1386
- const keyPresent = apiKey !== void 0 && apiKey !== "";
1435
+ const apiKeyEnvVar = typeof parsed2.apiKeyEnvVar === "string" ? parsed2.apiKeyEnvVar : "";
1436
+ const keyPresent = apiKey !== void 0 && apiKey !== "" || apiKeyEnvVar !== "";
1387
1437
  const cookieless = impersonated || cookielessFlag || keyPresent;
1388
1438
  const halfCookie = sessionId === "" !== (csrfToken === "");
1389
1439
  if (savedAtStr === "" || halfCookie || !cookieless && sessionId === "") {
@@ -1429,9 +1479,12 @@ var KeyringStore = class _KeyringStore {
1429
1479
  if (jwtRefresh !== void 0 && jwtRefresh !== "") {
1430
1480
  session.jwtRefresh = jwtRefresh;
1431
1481
  }
1432
- if (keyPresent) {
1482
+ if (apiKey !== void 0 && apiKey !== "") {
1433
1483
  session.apiKey = apiKey;
1434
1484
  }
1485
+ if (apiKeyEnvVar !== "") {
1486
+ session.apiKeyEnvVar = apiKeyEnvVar;
1487
+ }
1435
1488
  return session;
1436
1489
  }
1437
1490
  /**
@@ -1463,9 +1516,9 @@ var KeyringStore = class _KeyringStore {
1463
1516
  const impersonated = isImpersonatedSession(session);
1464
1517
  if (isCookielessSession(session)) {
1465
1518
  const hasJwt = session.jwtAccess !== void 0 && session.jwtAccess !== "";
1466
- if (!hasJwt && !hasApiKey(session)) {
1519
+ if (!hasJwt && !hasApiKey(session) && !hasApiKeyEnvVar(session)) {
1467
1520
  throw new Error(
1468
- "saveSession: cookie-less session must have a jwtAccess token or an apiKey"
1521
+ "saveSession: cookie-less session must have a jwtAccess token, an apiKey, or an apiKeyEnvVar"
1469
1522
  );
1470
1523
  }
1471
1524
  if (session.sessionId === "" !== (session.csrfToken === "")) {
@@ -1496,6 +1549,9 @@ var KeyringStore = class _KeyringStore {
1496
1549
  if (session.jwtApiKeyFingerprint !== void 0 && session.jwtApiKeyFingerprint !== "") {
1497
1550
  metaBlob.jwtApiKeyFingerprint = session.jwtApiKeyFingerprint;
1498
1551
  }
1552
+ if (hasApiKeyEnvVar(session)) {
1553
+ metaBlob.apiKeyEnvVar = session.apiKeyEnvVar;
1554
+ }
1499
1555
  if (impersonated) {
1500
1556
  metaBlob.impersonatorEmail = session.impersonatorEmail;
1501
1557
  if (session.impersonatedUserId !== void 0) {
@@ -2599,7 +2655,9 @@ var FileKeyringBackend = class _FileKeyringBackend {
2599
2655
  // Named as the Session field, since the blob is what `getSession` reads.
2600
2656
  ...creds.accessApiKeyFingerprint !== void 0 && {
2601
2657
  jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
2602
- }
2658
+ },
2659
+ // The env var's NAME only — the resolved key never enters any store.
2660
+ ...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar }
2603
2661
  });
2604
2662
  }
2605
2663
  static slotOf(creds, account) {
@@ -2627,11 +2685,16 @@ var FileKeyringBackend = class _FileKeyringBackend {
2627
2685
  const csrfToken = typeof parsed2.csrfToken === "string" ? parsed2.csrfToken : "";
2628
2686
  const apiKeyExpiresAt = typeof parsed2.apiKeyExpiresAt === "string" ? parsed2.apiKeyExpiresAt : "";
2629
2687
  const accessApiKeyFingerprint = typeof parsed2.jwtApiKeyFingerprint === "string" ? parsed2.jwtApiKeyFingerprint : "";
2688
+ const apiKeyEnvVar = typeof parsed2.apiKeyEnvVar === "string" ? parsed2.apiKeyEnvVar : "";
2630
2689
  return {
2631
2690
  ...sessionId !== "" && { sessionId },
2632
2691
  ...csrfToken !== "" && { csrfToken },
2633
2692
  ...apiKeyExpiresAt !== "" && { apiKeyExpiresAt },
2634
- ...accessApiKeyFingerprint !== "" && { accessApiKeyFingerprint }
2693
+ ...accessApiKeyFingerprint !== "" && { accessApiKeyFingerprint },
2694
+ // Set-if-present, never deleted on a blob that lost it (the
2695
+ // `apiKeyExpiresAt` rule): it is the identity's only credential pointer,
2696
+ // and a meta rewrite must not be able to brick the file.
2697
+ ...apiKeyEnvVar !== "" && { apiKeyEnvVar }
2635
2698
  };
2636
2699
  }
2637
2700
  async get(_service, account) {
@@ -2724,6 +2787,7 @@ var FileKeyringBackend = class _FileKeyringBackend {
2724
2787
  if (meta.apiKeyExpiresAt !== void 0) next.apiKeyExpiresAt = meta.apiKeyExpiresAt;
2725
2788
  if (meta.accessApiKeyFingerprint === void 0) delete next.accessApiKeyFingerprint;
2726
2789
  else next.accessApiKeyFingerprint = meta.accessApiKeyFingerprint;
2790
+ if (meta.apiKeyEnvVar !== void 0) next.apiKeyEnvVar = meta.apiKeyEnvVar;
2727
2791
  break;
2728
2792
  }
2729
2793
  }
@@ -25516,6 +25580,62 @@ function symbolForCurrencyCode(code) {
25516
25580
  return FX_CURRENCY_SYMBOLS.get(code.trim().toUpperCase()) ?? null;
25517
25581
  }
25518
25582
 
25583
+ // src/commands/_currencySymbol.ts
25584
+ var FIXED_SIGNS_RE = /"[$€£₪]"|[$€£₪]/g;
25585
+ var DEFAULT_NUMBER_FORMAT = "#,###";
25586
+ function stripFixedCurrencySigns(format) {
25587
+ return format ? format.replace(FIXED_SIGNS_RE, "") : format;
25588
+ }
25589
+ function withCurrencySign(format, sign) {
25590
+ const cleanSign = sign.replace(/"/g, "");
25591
+ const literal = `"${cleanSign}${/^[A-Za-z]+$/.test(cleanSign) ? " " : ""}"`;
25592
+ const base = format || DEFAULT_NUMBER_FORMAT;
25593
+ return base.split(";").map((section) => {
25594
+ if (section === "") return section;
25595
+ const leading = section.match(/^(\[[^\]]*\])*(?=.)/)?.[0] ?? "";
25596
+ const rest = section.slice(leading.length);
25597
+ const paren = rest.match(/^\((.*)\)$/);
25598
+ return paren ? `${leading}(${literal}${paren[1]})` : `${leading}${literal}${rest}`;
25599
+ }).join(";");
25600
+ }
25601
+ function parseWidgetOptions(raw) {
25602
+ if (raw === void 0 || raw === null || raw === "") return {};
25603
+ if (typeof raw === "object" && !Array.isArray(raw)) return raw;
25604
+ if (typeof raw !== "string") return null;
25605
+ try {
25606
+ const parsed2 = JSON.parse(raw);
25607
+ return typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2) ? parsed2 : null;
25608
+ } catch {
25609
+ return null;
25610
+ }
25611
+ }
25612
+ function readAutoCurrencySymbol(raw) {
25613
+ const opts = parseWidgetOptions(raw);
25614
+ if (opts === null) return null;
25615
+ return opts["autoCurrencySymbol"] === true;
25616
+ }
25617
+ function setAutoCurrencySymbol(raw, enabled) {
25618
+ const opts = parseWidgetOptions(raw);
25619
+ if (opts === null) {
25620
+ throw new Error("widget options is not a JSON object \u2014 refusing to rewrite it");
25621
+ }
25622
+ return JSON.stringify({ ...opts, autoCurrencySymbol: enabled });
25623
+ }
25624
+ function fixedSignOf(format) {
25625
+ if (typeof format !== "string") return null;
25626
+ const m = format.match(/^(?:\[[^\]]*\])*\(?"([^"]+)"/);
25627
+ const sign = m?.[1];
25628
+ return sign === void 0 ? null : sign.trim();
25629
+ }
25630
+ function describeCurrencySymbol(widget) {
25631
+ const auto = readAutoCurrencySymbol(widget["options"]);
25632
+ if (auto === true && widget["is_fx_aware"] === true) {
25633
+ return "Auto (follows the dashboard FX selector)";
25634
+ }
25635
+ const sign = fixedSignOf(widget["values_format"]);
25636
+ return sign === null ? "none" : `"${sign}" (fixed)`;
25637
+ }
25638
+
25519
25639
  // src/commands/fxConfig.ts
25520
25640
  function writeTable20(out, rows) {
25521
25641
  if (rows.length === 0) return;
@@ -26509,6 +26629,11 @@ async function runFxConvertWidgets(cfg) {
26509
26629
  "--force-sum has no meaning with --off (turning conversion off never touches the aggregator)"
26510
26630
  );
26511
26631
  }
26632
+ if (cfg.disable && !cfg.autoSymbol) {
26633
+ throw new Error(
26634
+ "--no-auto-symbol has no meaning with --off (turning conversion off always clears the Auto currency symbol)"
26635
+ );
26636
+ }
26512
26637
  let targetDashboards;
26513
26638
  if (cfg.all) {
26514
26639
  const dashboards = await list8(cfg.client, { signal: cfg.signal });
@@ -26557,6 +26682,7 @@ async function runFxConvertWidgets(cfg) {
26557
26682
  const v0 = vals !== null && vals.length === 1 ? vals[0] : null;
26558
26683
  const val = v0 !== null && typeof v0 === "object" ? v0 : null;
26559
26684
  const aggregator = val !== null ? String(val["aggregator"] ?? "").toUpperCase() : "";
26685
+ const autoSymbol = readAutoCurrencySymbol(raw["options"]);
26560
26686
  if (cfg.disable) {
26561
26687
  plan.push({
26562
26688
  dashboardId: target.id,
@@ -26565,7 +26691,8 @@ async function runFxConvertWidgets(cfg) {
26565
26691
  widgetName,
26566
26692
  aggregator,
26567
26693
  raw,
26568
- status: isAware ? "revert" : "already-off"
26694
+ status: isAware ? "revert" : "already-off",
26695
+ autoSymbol
26569
26696
  });
26570
26697
  continue;
26571
26698
  }
@@ -26597,12 +26724,25 @@ async function runFxConvertWidgets(cfg) {
26597
26724
  widgetName,
26598
26725
  aggregator,
26599
26726
  raw,
26600
- status: status2
26727
+ status: status2,
26728
+ autoSymbol
26601
26729
  });
26602
26730
  }
26603
26731
  }
26604
26732
  const subjectOf = (p) => `widget ${p.widgetId} "${p.widgetName}" (dashboard ${p.dashboardId} "${p.dashboardName}")`;
26605
- const describeEntry = (p) => planLine(p.status, subjectOf(p), p.aggregator);
26733
+ const symbolNote = (p) => {
26734
+ if (p.status === "convert" || p.status === "convert-sum") {
26735
+ return cfg.autoSymbol ? " (+ Auto currency symbol)" : "";
26736
+ }
26737
+ if (p.status === "already" && p.autoSymbol === false) {
26738
+ return ` (fixed currency symbol \u2014 \`dr widgets set-currency-symbol ${p.widgetId} -d ${p.dashboardId} --auto\` to follow the FX selector)`;
26739
+ }
26740
+ if (p.status === "revert" && p.autoSymbol === true) {
26741
+ return " (Auto currency symbol \u2192 none)";
26742
+ }
26743
+ return "";
26744
+ };
26745
+ const describeEntry = (p) => planLine(p.status, subjectOf(p), p.aggregator) + symbolNote(p);
26606
26746
  const targets = plan.filter((p) => isActionable(p.status, cfg.disable));
26607
26747
  const needsRole = plan.filter((p) => p.status === "needs-fx-role").length;
26608
26748
  const summary = () => {
@@ -26618,6 +26758,7 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26618
26758
  writeJSONIndented(cfg.out, {
26619
26759
  dry_run: true,
26620
26760
  direction: cfg.disable ? "off" : "on",
26761
+ auto_symbol: cfg.disable ? false : cfg.autoSymbol,
26621
26762
  scanned,
26622
26763
  not_eligible: notEligible,
26623
26764
  plan: plan.map((p) => ({
@@ -26626,7 +26767,8 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26626
26767
  widget_id: p.widgetId,
26627
26768
  name: p.widgetName,
26628
26769
  aggregator: p.aggregator,
26629
- status: p.status
26770
+ status: p.status,
26771
+ auto_currency_symbol: p.autoSymbol
26630
26772
  })),
26631
26773
  unreadable
26632
26774
  });
@@ -26666,10 +26808,19 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26666
26808
  const v0 = vals[0];
26667
26809
  body["vals"] = [{ ...v0, aggregator: "SUM" }];
26668
26810
  }
26811
+ let wantAuto = null;
26812
+ if (want && cfg.autoSymbol) wantAuto = true;
26813
+ else if (!want && p.autoSymbol === true) wantAuto = false;
26814
+ if (wantAuto !== null) body["options"] = setAutoCurrencySymbol(p.raw["options"], wantAuto);
26669
26815
  const res = await update6(cfg.client, p.dashboardId, p.widgetId, body, {
26670
26816
  signal: cfg.signal
26671
26817
  });
26672
26818
  assertFxAwareEcho(res["is_fx_aware"], want, "widget");
26819
+ if (wantAuto !== null && readAutoCurrencySymbol(res["options"]) !== wantAuto) {
26820
+ throw new Error(
26821
+ `server did not keep options.autoCurrencySymbol=${wantAuto} (the Auto currency symbol)`
26822
+ );
26823
+ }
26673
26824
  results.push({
26674
26825
  dashboard_id: p.dashboardId,
26675
26826
  widget_id: p.widgetId,
@@ -26678,8 +26829,10 @@ ${needsRole} skipped for a missing fx_role \u2014 ${FX_ROLE_HINT}.` : "");
26678
26829
  });
26679
26830
  if (!cfg.asJson) {
26680
26831
  for (const line of unreadableLines(unreadable, "dashboard")) cfg.out.write(line + "\n");
26681
- cfg.out.write(`\u2713 ${describeConvertStatus(p.status, subjectOf(p), p.aggregator)}
26682
- `);
26832
+ cfg.out.write(
26833
+ `\u2713 ${describeConvertStatus(p.status, subjectOf(p), p.aggregator)}${symbolNote(p)}
26834
+ `
26835
+ );
26683
26836
  }
26684
26837
  } catch (err) {
26685
26838
  if (cfg.signal.aborted) throw err;
@@ -27018,11 +27171,14 @@ function registerFxConfigCommands(fx) {
27018
27171
  addServerFlag(convertFunctionsCmd);
27019
27172
  addAccountFlag(convertFunctionsCmd);
27020
27173
  const convertWidgetsCmd = fx.command("convert-widgets").description(
27021
- "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."
27174
+ "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."
27022
27175
  ).option("--dashboard <id>", "Convert widgets on this dashboard only").option("--all", "Convert widgets on every dashboard", false).option(
27023
27176
  "--force-sum",
27024
27177
  "Also convert non-SUM widgets by switching their aggregator to SUM",
27025
27178
  false
27179
+ ).option(
27180
+ "--no-auto-symbol",
27181
+ "Keep each converted widget's stored number format (its fixed currency sign, if any) instead of switching it to the Auto currency symbol"
27026
27182
  ).option(
27027
27183
  "--off",
27028
27184
  "Reverse direction: turn currency conversion OFF on every currency-aware widget in scope",
@@ -27038,6 +27194,7 @@ function registerFxConfigCommands(fx) {
27038
27194
  all: opts.all === true,
27039
27195
  forceSum: opts.forceSum === true,
27040
27196
  disable: opts.off === true,
27197
+ autoSymbol: opts.autoSymbol !== false,
27041
27198
  confirm: opts.confirm === true,
27042
27199
  asJson: opts.json === true,
27043
27200
  signal: setup.signal,
@@ -41169,6 +41326,10 @@ async function runWidgetsGet(cfg) {
41169
41326
  cfg.out.write(`chart_type: ${found["chart_type"] ?? ""}
41170
41327
  `);
41171
41328
  cfg.out.write(`template_id: ${found["template_id"] ?? "(none)"}
41329
+ `);
41330
+ cfg.out.write(`fx_aware: ${found["is_fx_aware"] === true ? "yes" : "no"}
41331
+ `);
41332
+ cfg.out.write(`fx_symbol: ${describeCurrencySymbol(found)}
41172
41333
  `);
41173
41334
  cfg.out.write("\n(use --json for the full widget body)\n");
41174
41335
  }
@@ -41247,6 +41408,67 @@ async function runWidgetsUpdate(cfg) {
41247
41408
  });
41248
41409
  printWidgetMutation(cfg.out, updated, "Updated", cfg.asJson);
41249
41410
  }
41411
+ async function runWidgetsSetCurrencySymbol(cfg) {
41412
+ if (cfg.dashboardId <= 0) throw new Error("--dashboard is required");
41413
+ if (cfg.id <= 0) throw new Error("widget id must be positive");
41414
+ const sign = cfg.mode === "sign" ? (cfg.sign ?? "").trim() : "";
41415
+ if (cfg.mode === "sign" && (sign === "" || sign.length > 5 || sign.includes('"'))) {
41416
+ throw new Error(
41417
+ "--sign needs a currency sign of 1\u20135 characters without quotes (e.g. $, \u20AC, \xA3, \u20AA or a code such as CHF)"
41418
+ );
41419
+ }
41420
+ const all = await listRaw3(cfg.client, cfg.dashboardId, { signal: cfg.signal });
41421
+ const found = all.find((w) => w["id"] === cfg.id);
41422
+ if (found === void 0) {
41423
+ throw new Error(`widget ${cfg.id} not found on dashboard ${cfg.dashboardId}`);
41424
+ }
41425
+ if (cfg.mode === "auto" && found["is_fx_aware"] !== true) {
41426
+ throw new Error(
41427
+ `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`
41428
+ );
41429
+ }
41430
+ const wantAuto = cfg.mode === "auto";
41431
+ const current = typeof found["values_format"] === "string" ? found["values_format"] : null;
41432
+ const body = {
41433
+ ...found,
41434
+ options: setAutoCurrencySymbol(found["options"], wantAuto)
41435
+ };
41436
+ if (cfg.mode === "none") {
41437
+ body["values_format"] = stripFixedCurrencySigns(current) || null;
41438
+ } else if (cfg.mode === "sign") {
41439
+ body["values_format"] = withCurrencySign(stripFixedCurrencySigns(current), sign);
41440
+ }
41441
+ const res = await update6(cfg.client, cfg.dashboardId, cfg.id, body, {
41442
+ signal: cfg.signal
41443
+ });
41444
+ if (readAutoCurrencySymbol(res["options"]) !== wantAuto) {
41445
+ throw new Error(
41446
+ `server did not keep options.autoCurrencySymbol=${wantAuto} (the Auto currency symbol)`
41447
+ );
41448
+ }
41449
+ if (cfg.mode !== "auto" && (res["values_format"] ?? null) !== body["values_format"]) {
41450
+ throw new Error(
41451
+ `server did not keep values_format ${JSON.stringify(body["values_format"])} (came back ${JSON.stringify(res["values_format"] ?? null)})`
41452
+ );
41453
+ }
41454
+ if (cfg.asJson) {
41455
+ writeJSONIndented(cfg.out, {
41456
+ id: res["id"] ?? cfg.id,
41457
+ name: res["name"] ?? found["name"] ?? null,
41458
+ is_fx_aware: res["is_fx_aware"] === true,
41459
+ auto_currency_symbol: wantAuto,
41460
+ values_format: res["values_format"] ?? null,
41461
+ currency_symbol: describeCurrencySymbol(res)
41462
+ });
41463
+ return;
41464
+ }
41465
+ cfg.out.write(
41466
+ `widget ${cfg.id} "${String(found["name"] ?? "")}": currency symbol \u2192 ${describeCurrencySymbol(res)}
41467
+ `
41468
+ );
41469
+ cfg.out.write(`values_format: ${res["values_format"] ?? "(none)"}
41470
+ `);
41471
+ }
41250
41472
  async function runWidgetsDelete(cfg) {
41251
41473
  if (cfg.dashboardId <= 0) throw new Error("--dashboard is required");
41252
41474
  if (cfg.id <= 0) throw new Error("widget id must be positive");
@@ -41598,6 +41820,36 @@ function registerWidgetsCommand(root) {
41598
41820
  });
41599
41821
  addServerFlag(updateCmd);
41600
41822
  addAccountFlag(updateCmd);
41823
+ const symbolCmd = widgets.command("set-currency-symbol").description(
41824
+ "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."
41825
+ ).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(
41826
+ async (idStr, opts) => {
41827
+ const id = parseWidgetIdArg(idStr);
41828
+ const dashboardId = parseDashboardFlag(opts.dashboard);
41829
+ const picked = [opts.auto === true, opts.none === true, opts.sign !== void 0].filter(
41830
+ Boolean
41831
+ ).length;
41832
+ if (picked !== 1) throw new Error("pass exactly one of --auto, --none or --sign <S>");
41833
+ const mode = opts.auto === true ? "auto" : opts.none === true ? "none" : "sign";
41834
+ const setup = await setupCall(opts.server, opts.account);
41835
+ try {
41836
+ await runWidgetsSetCurrencySymbol({
41837
+ client: setup.client,
41838
+ dashboardId,
41839
+ id,
41840
+ mode,
41841
+ sign: opts.sign,
41842
+ asJson: opts.json === true,
41843
+ signal: setup.signal,
41844
+ out: process.stdout
41845
+ });
41846
+ } finally {
41847
+ setup.cleanup();
41848
+ }
41849
+ }
41850
+ );
41851
+ addServerFlag(symbolCmd);
41852
+ addAccountFlag(symbolCmd);
41601
41853
  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) => {
41602
41854
  const id = parseWidgetIdArg(idStr);
41603
41855
  const dashboardId = parseDashboardFlag(opts.dashboard);
@@ -44947,7 +45199,7 @@ import * as nodePath2 from "path";
44947
45199
  var API_PREFIX = "/api/v1";
44948
45200
  var OPERATOR_EMAIL_HEADER = "X-Operator-Email";
44949
45201
  function withOperatorAttribution(client, options) {
44950
- const operatorEmail = isImpersonatedSession(client.session) ? client.session.impersonatorEmail : client.session.cookieless === true && !hasApiKey(client.session) && client.session.interactiveLogin !== true ? void 0 : client.email;
45202
+ const operatorEmail = isImpersonatedSession(client.session) ? client.session.impersonatorEmail : client.session.cookieless === true && !hasApiKey(client.session) && !hasApiKeyEnvVar(client.session) && client.session.interactiveLogin !== true ? void 0 : client.email;
44951
45203
  if (operatorEmail === void 0) return options ?? {};
44952
45204
  return {
44953
45205
  ...options,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.97",
3
+ "cliVersion": "1.6.99",
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.97",
3
+ "version": "1.6.99",
4
4
  "description": "Datarails CLI",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@datarails-ai/dr-appspace-cli": "0.10.20",
20
+ "@datarails-ai/dr-appspace-cli": "0.10.21",
21
21
  "@modelcontextprotocol/sdk": "^1.29.0",
22
22
  "@napi-rs/keyring": "^1.3.0",
23
23
  "commander": "^12.1.0",
@@ -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",