@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 +68 -0
- package/dist/cli.js +289 -37
- package/dist/scopes-map.json +9 -1
- package/dist/skills/dr-cli/SKILL.md +5 -2
- package/dist/skills/dr-cli/reference/admin.md +29 -2
- package/dist/skills/dr-cli/reference/dashboards-widgets.md +7 -0
- package/dist/skills/fx-onboard/SKILL.md +10 -1
- package/package.json +4 -2
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.
|
|
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.
|
|
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-
|
|
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.
|
|
336
|
-
message: "
|
|
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(
|
|
339
|
-
|
|
340
|
-
|
|
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 (
|
|
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
|
-
|
|
971
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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(
|
|
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,
|
package/dist/scopes-map.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"cliVersion": "1.6.
|
|
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.
|
|
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
|
+
"@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",
|