@datarails-ai/dr-cli 1.6.100 → 1.6.102

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
@@ -162,19 +162,23 @@ JWT refresh is automatic. Re-run `login` only when the refresh token itself
162
162
  dies (days of idle, password change, admin revoke). Never paste the
163
163
  session/JWT — treat them like a password.
164
164
 
165
- **Trying the v2 device flow (`DR_OAUTH_FLOW=v2`).** Set `DR_OAUTH_FLOW=v2` for
166
- `dr login` to use the monolith's `/oauth/v2/` device flow. The consent page lets
167
- you pick the credential's scope (below) before "Allow", and the login stores a
168
- cookie-less JWT pair, with no session, refreshed at the external refresh
169
- endpoint. The page offers the choice only where the org enforces it; elsewhere
170
- the token is unrestricted, as today. Unset, or `legacy`, keeps the current flow;
171
- any other value is refused. The server must be running the v2 endpoints
172
- (DR-53508). Log in again to switch an identity between flows. Not yet
173
- supported with a credential file (`DR_CREDENTIALS_FILE`): log in through the
174
- keychain.
165
+ **Device flow and credential scope.** `dr login` uses the monolith's
166
+ `/oauth/v2/` device flow. The consent page lets you pick the credential's scope
167
+ (below) before "Allow", and the login stores a cookie-less JWT pair, with no
168
+ session, refreshed at the external refresh endpoint. The page offers the choice
169
+ only where the org enforces it; elsewhere the token is unrestricted. This works
170
+ with a credential file too (`DR_CREDENTIALS_FILE`), which records the login as
171
+ `"interactiveLogin": true` so that `dr login` and `dr logout` still accept it.
172
+
173
+ `DR_OAUTH_FLOW=legacy` runs the old `/oauth/device/code/` flow instead, for a
174
+ server that does not serve `/oauth/v2/` yet (a 404 from the v2 code endpoint
175
+ says so). A legacy token carries **no** credential scope, whatever the person
176
+ would have picked, and the login prints a line saying so. `v2` or unset selects
177
+ the default; any other value is refused. Log in again to switch an identity
178
+ between flows.
175
179
 
176
180
  ```
177
- DR_OAUTH_FLOW=v2 dr login --server US # PowerShell: $env:DR_OAUTH_FLOW="v2"; dr login --server US
181
+ DR_OAUTH_FLOW=legacy dr login --server US # PowerShell: $env:DR_OAUTH_FLOW="legacy"; dr login --server US
178
182
  ```
179
183
 
180
184
  ### Credential scope
@@ -393,6 +397,7 @@ dr skill print <name> # print an embedded skill's SKILL.md
393
397
  | Data models | `data-models list/get` |
394
398
  | Datamappers | `datamappers list/get/create/update/delete` |
395
399
  | Dashboards | `dashboards list/get/create/delete/prepare`, `dashboards set-date-picker <id> --mode <preset> \| --year YYYY \| --quarter YYYY-Qn \| --month YYYY-MM \| --day YYYY-MM-DD` |
400
+ | Folders | `folders list/create`, `folders reorder --container <root\|id> (--items folder:12,entity:87 \| --move <kind:id> --to-index <n>)`, `folders move <id> --parent <root\|id>` (dashboard / story picker tree, flag-gated; reorder and move are two-step confirm; a dashboard moves with `dashboards set-folder`) |
396
401
  | Functions | `functions list/get/create/delete` |
397
402
  | Dynamic ranges | `dynamic-ranges list/get/data/create/delete` |
398
403
  | Lookups (LUTs) | `lut list/create/update/delete/reorder/unmapped` (alias `lookups`) |
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.100",
11
+ version: "1.6.102",
12
12
  description: "Datarails CLI",
13
13
  type: "module",
14
14
  engines: {
@@ -40,7 +40,7 @@ var package_default = {
40
40
  postinstall: "node scripts/postinstall.mjs"
41
41
  },
42
42
  dependencies: {
43
- "@datarails-ai/dr-appspace-cli": "0.10.21",
43
+ "@datarails-ai/dr-appspace-cli": "0.10.22",
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@napi-rs/keyring": "^1.3.0",
46
46
  commander: "^12.1.0",
@@ -69,7 +69,7 @@ var package_default = {
69
69
  };
70
70
 
71
71
  // src/buildInfo.ts
72
- var BUILD_TIME = "2026-10-05T12:55:55.787Z";
72
+ var BUILD_TIME = "2026-10-07T11:02:43.513Z";
73
73
  function formatBuildTime(iso) {
74
74
  if (iso === "") return "";
75
75
  const d = new Date(iso);
@@ -336,6 +336,13 @@ var credentialsFileSchema = z.object({
336
336
  // would rewrite live token material after every successful command.
337
337
  sessionId: z.string().min(1).optional(),
338
338
  csrfToken: z.string().min(1).optional(),
339
+ // DR-53844: set by a v2 `dr login`, whose pair comes with no cookie, so the
340
+ // file still reads as a person's login — `dr login` may replace it and
341
+ // `dr logout` may remove it — rather than as one provisioned externally.
342
+ // Written by the CLI only. `z.boolean()`, not `z.literal(true)`: a
343
+ // top-level rejection fails the whole file (see `apiKeyExpiresAt`), and a
344
+ // hand-edited `false` must read as absent, not lock the operator out.
345
+ interactiveLogin: z.boolean().optional(),
339
346
  // Validated, not just typed: `KeyringStore` turns this straight into a Date
340
347
  // and later calls `toISOString()` on it, which throws on an Invalid Date.
341
348
  // `datetime()` rejects a bad calendar date (2026-02-30, 2026-13-40) on its
@@ -567,11 +574,18 @@ var UnknownAccountError = class extends Error {
567
574
  var SCOPE_DENIED_CODE = 40301;
568
575
  var SCOPE_UNDECLARED_CODE = 40302;
569
576
  var ScopeDeniedError = class extends Error {
570
- constructor(method, path11, code, required2) {
577
+ /**
578
+ * @param identity The identity whose ceiling refused, when it is NOT necessarily the one
579
+ * the command line selected — `dr login-as --as <operator>` mints with the operator's
580
+ * credential, not the `--server`/`--account` one. Named, the message points `dr whoami`
581
+ * at it; omitted, "this identity" is the one the reader just ran the command as.
582
+ */
583
+ constructor(method, path11, code, required2, identity2) {
571
584
  super("");
572
585
  this.method = method;
573
586
  this.path = path11;
574
587
  this.code = code;
588
+ this.identity = identity2;
575
589
  this.name = "ScopeDeniedError";
576
590
  this.required = required2;
577
591
  this.message = this.render();
@@ -579,6 +593,7 @@ var ScopeDeniedError = class extends Error {
579
593
  method;
580
594
  path;
581
595
  code;
596
+ identity;
582
597
  /** `resource:action` the command needs, joined when it needs more than one. Filled in by
583
598
  * `cli.ts` after construction (see the class docstring); `undefined` until then, and for
584
599
  * a command the local map says nothing about. */
@@ -597,6 +612,10 @@ var ScopeDeniedError = class extends Error {
597
612
  return `${this.method} ${this.path}: this endpoint is not yet scope-declared on the server; report to #dr-cli`;
598
613
  }
599
614
  const needs = this.required === void 0 || this.required === "" ? "" : ` (needs ${this.required})`;
615
+ if (this.identity !== void 0) {
616
+ const { server, account } = this.identity;
617
+ return `${this.method} ${this.path}: the credential scope of ${account} on ${server} does not allow it${needs}; run 'dr whoami --server ${server} --account ${account}' to see the scopes it carries`;
618
+ }
600
619
  return `${this.method} ${this.path}: this identity's credential scope does not allow it${needs}; run 'dr whoami' to see the scopes this identity carries`;
601
620
  }
602
621
  };
@@ -2657,7 +2676,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
2657
2676
  jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
2658
2677
  },
2659
2678
  // The env var's NAME only — the resolved key never enters any store.
2660
- ...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar }
2679
+ ...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar },
2680
+ ...creds.interactiveLogin === true && { interactiveLogin: true }
2661
2681
  });
2662
2682
  }
2663
2683
  static slotOf(creds, account) {
@@ -2694,7 +2714,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
2694
2714
  // Set-if-present, never deleted on a blob that lost it (the
2695
2715
  // `apiKeyExpiresAt` rule): it is the identity's only credential pointer,
2696
2716
  // and a meta rewrite must not be able to brick the file.
2697
- ...apiKeyEnvVar !== "" && { apiKeyEnvVar }
2717
+ ...apiKeyEnvVar !== "" && { apiKeyEnvVar },
2718
+ ...parsed2.interactiveLogin === true && { interactiveLogin: true }
2698
2719
  };
2699
2720
  }
2700
2721
  async get(_service, account) {
@@ -2788,6 +2809,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
2788
2809
  if (meta.accessApiKeyFingerprint === void 0) delete next.accessApiKeyFingerprint;
2789
2810
  else next.accessApiKeyFingerprint = meta.accessApiKeyFingerprint;
2790
2811
  if (meta.apiKeyEnvVar !== void 0) next.apiKeyEnvVar = meta.apiKeyEnvVar;
2812
+ if (meta.interactiveLogin === true) next.interactiveLogin = true;
2813
+ else delete next.interactiveLogin;
2791
2814
  break;
2792
2815
  }
2793
2816
  }
@@ -3659,6 +3682,11 @@ var PROD_GATE_TIER_B = /* @__PURE__ */ new Set([
3659
3682
  // cash (dr cash …). rules delete / delete-all / hidden-rules delete → Tier A.
3660
3683
  "cash-rule|update",
3661
3684
  "cash-rules|reorder",
3685
+ // dashboard / story folders (dr folders reorder, dr folders move). Reorder is
3686
+ // per-user (only the caller's own picker order changes) and a move is undone by
3687
+ // another move.
3688
+ "folder-container|reorder",
3689
+ "folder|move",
3662
3690
  "cash-hidden-rules|upsert",
3663
3691
  "cash-ai-rules|trigger",
3664
3692
  // metrics (dr metrics …). delete / dimension|category delete → A.
@@ -4290,9 +4318,17 @@ async function refuseIfProvisionedExternally(action, opts) {
4290
4318
  `cannot ${action}: the credentials in ${path11} are an API key. Run \`dr login --api-key\` to replace the key (and update its \`apiKeyExpiresAt\` in that file, if set \u2014 nothing derives that date, and a stale one warns "expired" on every command), or \`dr logout\` to remove it and log in with a browser instead \u2014 it cannot be read back afterwards, so keep a copy if it is still needed.`
4291
4319
  );
4292
4320
  }
4293
- if (creds.sessionId !== void 0 && creds.sessionId !== "") return;
4321
+ if (creds.apiKeyEnvVar !== void 0 && opts?.allowApiKey !== true) {
4322
+ throw new Error(
4323
+ `cannot ${action}: the credentials in ${path11} name the environment variable ${creds.apiKeyEnvVar} as their API key, which is what later commands would authenticate as. Remove that file (\`dr logout\`) to log in with a browser instead.`
4324
+ );
4325
+ }
4326
+ if (isInteractiveFileLogin(creds)) return;
4294
4327
  throw provisionedExternallyError(path11, action);
4295
4328
  }
4329
+ function isInteractiveFileLogin(creds) {
4330
+ return creds.sessionId !== void 0 && creds.sessionId !== "" || creds.interactiveLogin === true;
4331
+ }
4296
4332
  async function refuseLoginOverStoredApiKey(store, serverKey, email) {
4297
4333
  let existing;
4298
4334
  try {
@@ -4323,7 +4359,7 @@ async function refuseImpersonationWithCredentialFile() {
4323
4359
  `cannot ${action}: the credentials in ${path11} are an API key, and an API-key token cannot mint further credentials. Impersonating needs a browser login, so remove that file (\`dr logout\`) and run \`dr login --server <key>\` \u2014 the key cannot be read back afterwards, so keep a copy if it is still needed.`
4324
4360
  );
4325
4361
  }
4326
- if (creds.sessionId === void 0 || creds.sessionId === "") {
4362
+ if (!isInteractiveFileLogin(creds)) {
4327
4363
  throw provisionedExternallyError(path11, action);
4328
4364
  }
4329
4365
  throw new Error(
@@ -5141,8 +5177,8 @@ var OAUTH_FLOW_ENV = "DR_OAUTH_FLOW";
5141
5177
  function oauthFlowFromEnv(env = process.env) {
5142
5178
  const raw = (env[OAUTH_FLOW_ENV] ?? "").trim();
5143
5179
  const value = raw.toLowerCase();
5144
- if (value === "" || value === "legacy") return "legacy";
5145
- if (value === "v2") return "v2";
5180
+ if (value === "" || value === "v2") return "v2";
5181
+ if (value === "legacy") return "legacy";
5146
5182
  throw new Error(`${OAUTH_FLOW_ENV} must be "legacy" or "v2", got "${raw}"`);
5147
5183
  }
5148
5184
  var PATHS = {
@@ -5152,7 +5188,7 @@ var PATHS = {
5152
5188
  var GRANT_TYPE = "urn:ietf:params:oauth:grant-type:";
5153
5189
  async function requestDeviceCode(host, options) {
5154
5190
  const base = host.replace(/\/+$/, "");
5155
- const flow = options?.flow ?? "legacy";
5191
+ const flow = options?.flow ?? "v2";
5156
5192
  const paths = PATHS[flow];
5157
5193
  const body = new URLSearchParams({ client_id: CLIENT_ID, scope: paths.scope });
5158
5194
  const res = await fetch(`${base}${paths.code}`, {
@@ -5163,7 +5199,12 @@ async function requestDeviceCode(host, options) {
5163
5199
  });
5164
5200
  const text2 = await res.text();
5165
5201
  if (!res.ok) {
5166
- const hint = flow === "v2" && res.status === 429 ? " (too many logins from this address: wait a minute and run `dr login` again)" : "";
5202
+ let hint = "";
5203
+ if (flow === "v2" && res.status === 429) {
5204
+ hint = " (too many logins from this address: wait a minute and run `dr login` again)";
5205
+ } else if (flow === "v2" && res.status === 404) {
5206
+ hint = ` (this server does not serve the v2 device flow yet: set ${OAUTH_FLOW_ENV}=legacy to log in through the old one, whose token carries no credential scope)`;
5207
+ }
5167
5208
  throw new Error(`device/code failed: ${res.status} ${text2.trim()}${hint}`);
5168
5209
  }
5169
5210
  let raw;
@@ -5530,11 +5571,6 @@ function registerLoginCommand(root) {
5530
5571
  await refuseIfProvisionedExternally("log in");
5531
5572
  const flow = oauthFlowFromEnv();
5532
5573
  const fileStore = await resolveFileStore(opts.server);
5533
- if (flow === "v2" && fileStore !== void 0) {
5534
- throw new Error(
5535
- `DR_OAUTH_FLOW=v2 cannot log into the credential file ${fileStore.path} yet; unset DR_CREDENTIALS_FILE to log in through the keychain, or unset DR_OAUTH_FLOW.`
5536
- );
5537
- }
5538
5574
  let serverKey = fileStore?.key ?? (opts.server ?? "").trim();
5539
5575
  if (fileStore === void 0) {
5540
5576
  if (serverKey === "") {
@@ -5643,13 +5679,14 @@ async function runLogin(cfg) {
5643
5679
  await refuseIfProvisionedExternally("log in");
5644
5680
  const host = cfg.host ?? resolve(cfg.serverKey).host;
5645
5681
  const timeoutMs = cfg.timeoutMs !== void 0 && cfg.timeoutMs > 0 ? cfg.timeoutMs : DEFAULT_LOGIN_TIMEOUT_MS;
5646
- const flow = cfg.flow ?? "legacy";
5682
+ const flow = cfg.flow;
5647
5683
  const controller = new AbortController();
5648
5684
  const overallTimer = setTimeout(() => controller.abort(), timeoutMs + ABORT_SLACK_MS);
5649
5685
  try {
5650
- if (flow === "v2") {
5651
- cfg.out.write(`Using the v2 device flow (DR_OAUTH_FLOW=v2).
5652
- `);
5686
+ if (flow === "legacy") {
5687
+ cfg.out.write(
5688
+ "Using the legacy device flow (DR_OAUTH_FLOW=legacy): its token carries no credential scope.\n"
5689
+ );
5653
5690
  }
5654
5691
  const dc = await requestDeviceCode(host, { signal: controller.signal, flow });
5655
5692
  cfg.out.write(`Opening browser to authenticate with Datarails (${cfg.serverKey})...
@@ -6059,7 +6096,17 @@ async function resolveTargetUserId(client, targetEmail, signal) {
6059
6096
  `no user you can log in as matches "${targetEmail}".${near} Use --user-id <n> to impersonate by id.`
6060
6097
  );
6061
6098
  }
6062
- function friendlyLoginAsError(err, targetEmail) {
6099
+ var LOGINAS_REQUIRED_SCOPE = "support-manager:write";
6100
+ function friendlyLoginAsError(err, targetEmail, explicitOperator) {
6101
+ if (err.code === "insufficient_scope" && err.httpStatus === 403) {
6102
+ return new ScopeDeniedError(
6103
+ "POST",
6104
+ "/jwt/api/loginas/",
6105
+ SCOPE_DENIED_CODE,
6106
+ LOGINAS_REQUIRED_SCOPE,
6107
+ explicitOperator
6108
+ );
6109
+ }
6063
6110
  switch (err.code) {
6064
6111
  case "not_permitted":
6065
6112
  return new Error(`You are not permitted to impersonate ${targetEmail}.`);
@@ -6119,7 +6166,10 @@ async function runLoginAs(cfg) {
6119
6166
  signal: cfg.signal
6120
6167
  });
6121
6168
  } catch (err) {
6122
- if (err instanceof LoginAsError) throw friendlyLoginAsError(err, cfg.targetEmail);
6169
+ if (err instanceof LoginAsError) {
6170
+ const explicitOperator = cfg.operatorFlag !== void 0 && cfg.operatorFlag !== "" ? { server: cfg.serverKey, account: operatorEmail } : void 0;
6171
+ throw friendlyLoginAsError(err, cfg.targetEmail, explicitOperator);
6172
+ }
6123
6173
  throw err;
6124
6174
  }
6125
6175
  let storageEmail = cfg.targetEmail;
@@ -14346,8 +14396,15 @@ function registerDashboardsCommand(root) {
14346
14396
  addAccountFlag(setFolderCmd);
14347
14397
  }
14348
14398
 
14399
+ // src/commands/folders.ts
14400
+ import { createHash as createHash3 } from "crypto";
14401
+
14349
14402
  // src/api/folders.ts
14350
14403
  var FOLDER_ENTITY_TYPES = ["dashboard", "story"];
14404
+ var FOLDER_FLAGS = {
14405
+ dashboard: "dashboards__folders",
14406
+ story: "storytelling__folders"
14407
+ };
14351
14408
  async function list9(client, entityType, options) {
14352
14409
  const out = await client.getJSON(
14353
14410
  `/api/folders/?entity_type=${entityType}`,
@@ -14364,6 +14421,39 @@ async function create4(client, entityType, name, parentId, options) {
14364
14421
  }
14365
14422
  return out;
14366
14423
  }
14424
+ async function reorder(client, payload, options) {
14425
+ const path11 = "/api/folders/reorder";
14426
+ const res = await client.do("POST", path11, JSON.stringify(payload), options);
14427
+ if (!res.ok) {
14428
+ throw new Error(`POST ${path11}: HTTP ${res.status}: ${await res.text()}`);
14429
+ }
14430
+ }
14431
+ async function move(client, folderId, parentId, options) {
14432
+ const path11 = `/api/folders/${folderId}`;
14433
+ const out = await client.patchJSON(path11, { parent_id: parentId }, options);
14434
+ if (out === null) {
14435
+ throw new Error(`unexpected empty response from PATCH ${path11}`);
14436
+ }
14437
+ return out;
14438
+ }
14439
+ var STORY_PAGE_SIZE = 100;
14440
+ async function listEntities(client, entityType, options) {
14441
+ if (entityType === "dashboard") {
14442
+ return await client.getJSON("/api/dashboards/", options) ?? [];
14443
+ }
14444
+ const stories = [];
14445
+ for (let page = 1; ; page++) {
14446
+ const env = await client.getJSON(
14447
+ `/api/storytelling/?page_size=${STORY_PAGE_SIZE}&page=${page}`,
14448
+ options
14449
+ );
14450
+ if (env === null || env.results.length === 0) break;
14451
+ stories.push(...env.results);
14452
+ if (env.next === null) break;
14453
+ }
14454
+ const listed = new Set(stories.map((s) => s.id));
14455
+ return stories.filter((s) => s.parent_story === null || s.is_roll_forwarded || !listed.has(s.parent_story)).map(({ id, name, folder_id, position }) => ({ id, name, folder_id, position }));
14456
+ }
14367
14457
 
14368
14458
  // src/commands/folders.ts
14369
14459
  async function runFoldersList(cfg) {
@@ -14373,6 +14463,235 @@ async function runFoldersList(cfg) {
14373
14463
  return;
14374
14464
  }
14375
14465
  renderFolderTree(cfg.out, tree);
14466
+ const entities = await listEntities(cfg.client, cfg.entityType, {
14467
+ signal: cfg.signal
14468
+ });
14469
+ if (tree.length === 0 && entities.length > 0 && entities.every((e) => e.position === null)) {
14470
+ cfg.out.write(
14471
+ `
14472
+ folders are disabled for ${cfg.entityType} on this server (Unleash flag ${FOLDER_FLAGS[cfg.entityType]} is off) \u2014 no member order to show.
14473
+ `
14474
+ );
14475
+ return;
14476
+ }
14477
+ const names = /* @__PURE__ */ new Map();
14478
+ const walk = (n) => {
14479
+ names.set(n.id, n.name);
14480
+ for (const c of n.children ?? []) walk(c);
14481
+ };
14482
+ for (const root of tree) walk(root);
14483
+ for (const [containerId, members] of buildContainerMembers(tree, entities)) {
14484
+ cfg.out.write(`
14485
+ ${containerLabel(containerId, names)}:
14486
+ `);
14487
+ if (members.length === 0) {
14488
+ cfg.out.write(" (empty)\n");
14489
+ continue;
14490
+ }
14491
+ const rows = members.map((m) => ({
14492
+ cells: [` ${m.kind}:${m.id}`, m.name, String(m.position ?? "-")]
14493
+ }));
14494
+ writeTable9(cfg.out, rows);
14495
+ }
14496
+ }
14497
+ function buildContainerMembers(tree, entities) {
14498
+ const out = /* @__PURE__ */ new Map();
14499
+ const add3 = (containerId, children2) => {
14500
+ out.set(
14501
+ containerId,
14502
+ children2.map((f) => ({
14503
+ kind: "folder",
14504
+ id: f.id,
14505
+ name: f.name,
14506
+ position: f.position ?? null
14507
+ }))
14508
+ );
14509
+ for (const f of children2) add3(f.id, f.children ?? []);
14510
+ };
14511
+ add3(null, tree);
14512
+ for (const e of entities) {
14513
+ const bucket = out.get(e.folder_id);
14514
+ if (bucket === void 0) continue;
14515
+ bucket.push({ kind: "entity", id: e.id, name: e.name, position: e.position });
14516
+ }
14517
+ const rank = (m) => m.position ?? Number.MAX_SAFE_INTEGER;
14518
+ for (const members of out.values()) {
14519
+ members.sort(
14520
+ (a, b) => rank(a) - rank(b) || (a.kind === b.kind ? 0 : a.kind === "folder" ? -1 : 1) || a.id - b.id
14521
+ );
14522
+ }
14523
+ return out;
14524
+ }
14525
+ var ITEM_RE = /^(folder|entity):([1-9][0-9]*)$/;
14526
+ function parseReorderItems(raw) {
14527
+ const tokens = raw.split(",").map((t) => t.trim());
14528
+ return tokens.map((t) => {
14529
+ const m = ITEM_RE.exec(t);
14530
+ if (m === null) {
14531
+ throw new Error(`invalid --items entry "${t}" (expected: folder:<id> | entity:<id>)`);
14532
+ }
14533
+ return { kind: m[1], id: Number(m[2]) };
14534
+ });
14535
+ }
14536
+ function parseContainerOpt(raw, flag) {
14537
+ if (raw === "root") return null;
14538
+ if (!/^[1-9][0-9]*$/.test(raw)) {
14539
+ throw new Error(`invalid ${flag} "${raw}" (expected: root | positive folder id)`);
14540
+ }
14541
+ return Number(raw);
14542
+ }
14543
+ function moveItem(items, item, toIndex) {
14544
+ const from = items.findIndex((i) => i.kind === item.kind && i.id === item.id);
14545
+ if (from === -1) {
14546
+ throw new Error(`${item.kind}:${item.id} is not in this container`);
14547
+ }
14548
+ const rest = items.filter((_, i) => i !== from).map((i) => ({ kind: i.kind, id: i.id }));
14549
+ rest.splice(Math.min(toIndex, rest.length), 0, { kind: item.kind, id: item.id });
14550
+ return rest;
14551
+ }
14552
+ function containerLabel(containerId, names) {
14553
+ if (containerId === null) return "root";
14554
+ const name = names.get(containerId);
14555
+ return name === void 0 ? `folder ${containerId}` : `folder ${containerId} (${name})`;
14556
+ }
14557
+ var itemKey = (i) => `${i.kind}:${i.id}`;
14558
+ function reorderConfirmId(entityType, containerArg, request) {
14559
+ const sha = createHash3("sha256").update(request).digest("hex").slice(0, 12);
14560
+ return `${entityType}:${containerArg}:${sha}`;
14561
+ }
14562
+ function flagOffError(entityType, err) {
14563
+ return new Error(
14564
+ `folders are not enabled for ${entityType} on this server (Unleash flag ${FOLDER_FLAGS[entityType]} is off?): ${err.message}`
14565
+ );
14566
+ }
14567
+ async function runFoldersReorder(cfg) {
14568
+ const hasItems = cfg.items !== void 0 && cfg.items.trim() !== "";
14569
+ const hasMove = cfg.move !== void 0 && cfg.move.trim() !== "";
14570
+ if (hasItems && hasMove) throw new Error("--items and --move are mutually exclusive");
14571
+ if (!hasItems && !hasMove) throw new Error("one of --items or --move is required");
14572
+ let toIndex = 0;
14573
+ if (hasMove) {
14574
+ if (cfg.toIndex === void 0 || !/^[0-9]+$/.test(cfg.toIndex)) {
14575
+ throw new Error("--move needs --to-index <n> (non-negative integer)");
14576
+ }
14577
+ toIndex = Number(cfg.toIndex);
14578
+ }
14579
+ const given = parseReorderItems(hasItems ? cfg.items ?? "" : cfg.move ?? "");
14580
+ if (hasMove && given.length !== 1) throw new Error("--move takes a single kind:id");
14581
+ const containerArg = cfg.container === null ? "root" : String(cfg.container);
14582
+ const firstGiven = given[0];
14583
+ const request = hasMove && firstGiven !== void 0 ? `move:${itemKey(firstGiven)}@${toIndex}` : given.map(itemKey).join(",");
14584
+ const action = {
14585
+ resource: "folder-container",
14586
+ id: reorderConfirmId(cfg.entityType, containerArg, request),
14587
+ verb: "reorder"
14588
+ };
14589
+ await preflightConfirmToken(action, cfg.token);
14590
+ const opts = { signal: cfg.signal };
14591
+ const [tree, entities] = await Promise.all([
14592
+ list9(cfg.client, cfg.entityType, opts),
14593
+ listEntities(cfg.client, cfg.entityType, opts)
14594
+ ]);
14595
+ const current = buildContainerMembers(tree, entities).get(cfg.container) ?? [];
14596
+ const items = hasMove && firstGiven !== void 0 ? moveItem(current, firstGiven, toIndex) : given;
14597
+ const payload = {
14598
+ entity_type: cfg.entityType,
14599
+ container_id: cfg.container,
14600
+ items
14601
+ };
14602
+ const flags = hasItems ? `--items ${cfg.items?.replace(/\s+/g, "") ?? ""}` : `--move ${cfg.move?.trim() ?? ""} --to-index ${toIndex}`;
14603
+ await twoStepConfirm({
14604
+ out: cfg.out,
14605
+ action,
14606
+ token: cfg.token,
14607
+ server: cfg.serverFlag,
14608
+ confirmCmd: `dr folders reorder --entity-type ${cfg.entityType} --container ${containerArg} ${flags} --server ${cfg.serverFlag}`,
14609
+ describe: async () => {
14610
+ const byKey2 = new Map(current.map((m, i) => [itemKey(m), { m, i }]));
14611
+ const lines = [`container: ${containerArg} (${cfg.entityType})`, "current order:"];
14612
+ if (current.length === 0) lines.push(" (empty)");
14613
+ current.forEach((m, i) => lines.push(` ${i} ${itemKey(m)} ${m.name}`));
14614
+ lines.push("new order:");
14615
+ items.forEach((it, i) => {
14616
+ const was = byKey2.get(itemKey(it));
14617
+ const note = was === void 0 ? "not in container" : was.i === i ? "" : `was ${was.i}`;
14618
+ const name = was?.m.name ?? "?";
14619
+ lines.push(` ${i} ${itemKey(it)} ${name}${note === "" ? "" : ` (${note})`}`);
14620
+ });
14621
+ const sent = new Set(items.map(itemKey));
14622
+ const missing = current.filter((m) => !sent.has(itemKey(m))).map(itemKey);
14623
+ const foreign = items.filter((it) => !byKey2.has(itemKey(it))).map(itemKey);
14624
+ if (missing.length > 0) lines.push(`missing from payload: ${missing.join(", ")}`);
14625
+ if (foreign.length > 0) lines.push(`not in container: ${foreign.join(", ")}`);
14626
+ lines.push(`payload: ${JSON.stringify(payload)}`);
14627
+ return lines;
14628
+ },
14629
+ execute: async () => {
14630
+ try {
14631
+ await reorder(cfg.client, payload, opts);
14632
+ } catch (err) {
14633
+ if (isNotFoundError(err) && err instanceof Error) throw flagOffError(cfg.entityType, err);
14634
+ throw err;
14635
+ }
14636
+ const where = cfg.container === null ? "root" : `folder ${cfg.container}`;
14637
+ cfg.out.write(`Reordered ${items.length} items in ${where}.
14638
+ `);
14639
+ }
14640
+ });
14641
+ }
14642
+ async function runFoldersMove(cfg) {
14643
+ const parentArg = cfg.parentId === null ? "root" : String(cfg.parentId);
14644
+ const action = {
14645
+ resource: "folder",
14646
+ id: `${cfg.entityType}:${cfg.folderId}->${parentArg}`,
14647
+ verb: "move"
14648
+ };
14649
+ await preflightConfirmToken(action, cfg.token);
14650
+ const opts = { signal: cfg.signal };
14651
+ await twoStepConfirm({
14652
+ out: cfg.out,
14653
+ action,
14654
+ token: cfg.token,
14655
+ server: cfg.serverFlag,
14656
+ confirmCmd: `dr folders move ${cfg.folderId} --parent ${parentArg} --entity-type ${cfg.entityType} --server ${cfg.serverFlag}`,
14657
+ describe: async () => {
14658
+ const tree = await list9(cfg.client, cfg.entityType, opts);
14659
+ const byId = /* @__PURE__ */ new Map();
14660
+ const walk = (n) => {
14661
+ byId.set(n.id, n);
14662
+ for (const c of n.children ?? []) walk(c);
14663
+ };
14664
+ for (const root of tree) walk(root);
14665
+ const names = new Map([...byId].map(([id, n]) => [id, n.name]));
14666
+ const folder = byId.get(cfg.folderId);
14667
+ const to = containerLabel(cfg.parentId, names);
14668
+ if (folder === void 0) {
14669
+ return [
14670
+ `folder ${cfg.folderId}: not in the ${cfg.entityType} tree (wrong --entity-type, flag off, or no access) -> ${to}`
14671
+ ];
14672
+ }
14673
+ return [
14674
+ `folder ${folder.id} (${folder.name}): ${containerLabel(folder.parent_id, names)} -> ${to}`,
14675
+ "the folder is appended at the end of the destination (Admin / Data Manager only)"
14676
+ ];
14677
+ },
14678
+ execute: async () => {
14679
+ try {
14680
+ await move(cfg.client, cfg.folderId, cfg.parentId, opts);
14681
+ } catch (err) {
14682
+ if (isNotFoundError(err) && err instanceof Error) {
14683
+ throw new Error(
14684
+ `folder ${cfg.folderId} not found, or folders are disabled (${FOLDER_FLAGS[cfg.entityType]}): ${err.message}`
14685
+ );
14686
+ }
14687
+ throw err;
14688
+ }
14689
+ cfg.out.write(
14690
+ `Moved folder ${cfg.folderId} to ${parentArg === "root" ? "root" : `folder ${parentArg}`}.
14691
+ `
14692
+ );
14693
+ }
14694
+ });
14376
14695
  }
14377
14696
  async function runFoldersCreate(cfg) {
14378
14697
  if (cfg.name === "") {
@@ -14505,6 +14824,70 @@ function registerFoldersCommand(root) {
14505
14824
  );
14506
14825
  addServerFlag(createCmd);
14507
14826
  addAccountFlag(createCmd);
14827
+ const reorderCmd = cmd.command("reorder").description(
14828
+ "Set the interleaved order of one container (two-step confirm). The order is per-user. --items sends exactly what is given; --move splices the current members and sends them all."
14829
+ ).option(
14830
+ "--entity-type <t>",
14831
+ `folder entity type: ${FOLDER_ENTITY_TYPES.join(" | ")}`,
14832
+ "dashboard"
14833
+ ).requiredOption("--container <c>", "root | folder id").option("--items <list>", "full ordered list, e.g. folder:12,entity:87 (sent verbatim)").option("--move <kind:id>", "item to move, e.g. entity:87").option("--to-index <n>", "0-based target index for --move (past the end = last)").option("--confirm-token <token>", "Token from a prior plan run \u2014 required to actually reorder").action(
14834
+ async (opts) => {
14835
+ const entityType = parseEntityTypeOpt(opts.entityType ?? "dashboard");
14836
+ const container = parseContainerOpt(opts.container, "--container");
14837
+ const serverKey = resolveServer(opts.server);
14838
+ const setup = await setupCall(opts.server, opts.account);
14839
+ try {
14840
+ await runFoldersReorder({
14841
+ client: setup.client,
14842
+ entityType,
14843
+ container,
14844
+ items: opts.items,
14845
+ move: opts.move,
14846
+ toIndex: opts.toIndex,
14847
+ token: opts.confirmToken,
14848
+ serverFlag: serverKey,
14849
+ signal: setup.signal,
14850
+ out: process.stdout
14851
+ });
14852
+ } finally {
14853
+ setup.cleanup();
14854
+ }
14855
+ }
14856
+ );
14857
+ addServerFlag(reorderCmd);
14858
+ addAccountFlag(reorderCmd);
14859
+ const moveCmd = cmd.command("move").description(
14860
+ "Move a folder under another folder or to root (two-step confirm). Appends at the end of the destination; Admin / Data Manager only."
14861
+ ).argument("<folderId>", "folder id (positive integer)").requiredOption("--parent <p>", "root | destination folder id").option(
14862
+ "--entity-type <t>",
14863
+ `tree to describe the move from: ${FOLDER_ENTITY_TYPES.join(" | ")}`,
14864
+ "dashboard"
14865
+ ).option("--confirm-token <token>", "Token from a prior plan run \u2014 required to actually move").action(
14866
+ async (idStr, opts) => {
14867
+ const folderId = parseContainerOpt(idStr, "<folderId>");
14868
+ if (folderId === null) throw new Error('invalid <folderId> "root"');
14869
+ const parentId = parseContainerOpt(opts.parent, "--parent");
14870
+ const entityType = parseEntityTypeOpt(opts.entityType ?? "dashboard");
14871
+ const serverKey = resolveServer(opts.server);
14872
+ const setup = await setupCall(opts.server, opts.account);
14873
+ try {
14874
+ await runFoldersMove({
14875
+ client: setup.client,
14876
+ entityType,
14877
+ folderId,
14878
+ parentId,
14879
+ token: opts.confirmToken,
14880
+ serverFlag: serverKey,
14881
+ signal: setup.signal,
14882
+ out: process.stdout
14883
+ });
14884
+ } finally {
14885
+ setup.cleanup();
14886
+ }
14887
+ }
14888
+ );
14889
+ addServerFlag(moveCmd);
14890
+ addAccountFlag(moveCmd);
14508
14891
  }
14509
14892
 
14510
14893
  // src/api/dynamicRanges.ts
@@ -17020,7 +17403,7 @@ async function update3(client, id, body, options) {
17020
17403
  async function deleteById6(client, id, options) {
17021
17404
  await client.deleteJSON(`/api/templates/lutdocuments/${id}`, options);
17022
17405
  }
17023
- async function reorder(client, body, options) {
17406
+ async function reorder2(client, body, options) {
17024
17407
  await client.postJSON("/api/templates/lutdocuments/update_orders", body, options);
17025
17408
  }
17026
17409
  var LUT_SAVE_POLL_INTERVAL_MS = 2e3;
@@ -18150,7 +18533,7 @@ async function runLutReorder(cfg) {
18150
18533
  body = await readJsonPayload(payloadFile);
18151
18534
  count = Array.isArray(body["objects"]) ? body["objects"].length : 0;
18152
18535
  }
18153
- await reorder(cfg.client, body, { signal: cfg.signal });
18536
+ await reorder2(cfg.client, body, { signal: cfg.signal });
18154
18537
  cfg.out.write(`Reordered ${count} lookup${plural(count)}.
18155
18538
  `);
18156
18539
  }
@@ -20116,7 +20499,7 @@ function parseDimensionFilters(raw) {
20116
20499
  }
20117
20500
 
20118
20501
  // src/commands/metricsCreate.ts
20119
- import { createHash as createHash3 } from "crypto";
20502
+ import { createHash as createHash4 } from "crypto";
20120
20503
 
20121
20504
  // src/api/metricsMonolith.ts
20122
20505
  async function create9(client, body, options) {
@@ -23568,7 +23951,7 @@ async function runMetricsDelete(cfg) {
23568
23951
  });
23569
23952
  }
23570
23953
  function shortHash(input) {
23571
- return createHash3("sha256").update(input).digest("hex").slice(0, 12);
23954
+ return createHash4("sha256").update(input).digest("hex").slice(0, 12);
23572
23955
  }
23573
23956
  function buildMetricsBatchDeleteAction(ids) {
23574
23957
  const sorted = [...new Set(ids)].sort((a, b) => a - b);
@@ -25381,7 +25764,7 @@ async function getOrgReportingCurrencies(client, organizationId, options) {
25381
25764
  }
25382
25765
 
25383
25766
  // src/commands/fxConfig.ts
25384
- import { createHash as createHash4 } from "crypto";
25767
+ import { createHash as createHash5 } from "crypto";
25385
25768
 
25386
25769
  // src/api/widgets.ts
25387
25770
  async function list18(client, dashboardId, options) {
@@ -25944,7 +26327,7 @@ async function runFxSetPosting(cfg) {
25944
26327
  verb: "set-fx"
25945
26328
  } : {
25946
26329
  resource: "field-batch",
25947
- id: createHash4("sha256").update(
26330
+ id: createHash5("sha256").update(
25948
26331
  changes.map((c) => `${c.field.id}:posting`).slice().sort().join("|")
25949
26332
  ).digest("hex").slice(0, 12),
25950
26333
  verb: "set-fx"
@@ -26398,7 +26781,7 @@ function buildSetReportingAction(changes) {
26398
26781
  };
26399
26782
  }
26400
26783
  const canonical = changes.map((c) => `${c.field.id}:reporting:${c.code}:${c.symbol ?? ""}`).slice().sort().join("|");
26401
- const sha = createHash4("sha256").update(canonical).digest("hex").slice(0, 12);
26784
+ const sha = createHash5("sha256").update(canonical).digest("hex").slice(0, 12);
26402
26785
  return { resource: "field-batch", id: sha, verb: "set-fx" };
26403
26786
  }
26404
26787
  function renderReportingOutcome(out, results, skipped, notFxTables, asJson) {
@@ -27378,7 +27761,7 @@ function registerFxCommand(root) {
27378
27761
  }
27379
27762
 
27380
27763
  // src/commands/errorEnrichments.ts
27381
- import { createHash as createHash5 } from "crypto";
27764
+ import { createHash as createHash6 } from "crypto";
27382
27765
  import { promises as fs13 } from "fs";
27383
27766
  import * as path4 from "path";
27384
27767
 
@@ -27918,7 +28301,7 @@ async function runEnrichmentsExport(cfg) {
27918
28301
  requireSweepSuccess(results);
27919
28302
  }
27920
28303
  function shortHash2(input) {
27921
- return createHash5("sha256").update(input).digest("hex").slice(0, 12);
28304
+ return createHash6("sha256").update(input).digest("hex").slice(0, 12);
27922
28305
  }
27923
28306
  function canonicalBatch(parts) {
27924
28307
  return stableJson(parts);
@@ -28713,7 +29096,7 @@ function registerErrorEnrichmentsCommand(root) {
28713
29096
  }
28714
29097
 
28715
29098
  // src/commands/skill.ts
28716
- import { createHash as createHash6 } from "crypto";
29099
+ import { createHash as createHash7 } from "crypto";
28717
29100
  import { readFile as readFile3, mkdir as mkdir2, readdir as readdir2, rm as rm2, rmdir, stat, writeFile as writeFile3 } from "fs/promises";
28718
29101
  import { dirname as dirname6, join as join8, resolve as resolve2 } from "path";
28719
29102
 
@@ -28875,7 +29258,7 @@ async function runSkillPrint(cfg) {
28875
29258
  throw new Error(`skill "${skill.name}" has no file "${relPath}" \u2014 available: ${available}`);
28876
29259
  }
28877
29260
  if (cfg.hash) {
28878
- const hex = createHash6("sha256").update(hit.bytes).digest("hex");
29261
+ const hex = createHash7("sha256").update(hit.bytes).digest("hex");
28879
29262
  cfg.out.write(hex + "\n");
28880
29263
  return;
28881
29264
  }
@@ -32740,7 +33123,7 @@ async function itResync(client, id, reqOptions) {
32740
33123
  // src/commands/filebox.ts
32741
33124
  import { promises as fsPromises, existsSync as existsSync2 } from "fs";
32742
33125
  import * as nodePath from "path";
32743
- import { createHash as createHash7 } from "crypto";
33126
+ import { createHash as createHash8 } from "crypto";
32744
33127
 
32745
33128
  // src/commands/filebox_admin.ts
32746
33129
  import { promises as fs15 } from "fs";
@@ -35369,7 +35752,7 @@ function buildIncludeBatchAction(docId, include, targets) {
35369
35752
  const canonical = `${docId}|${include ? "on" : "off"}|${targets.map((v) => v.id).slice().sort((a, b) => a - b).join(",")}`;
35370
35753
  return {
35371
35754
  resource: "filebox-version-batch",
35372
- id: createHash7("sha256").update(canonical).digest("hex").slice(0, 12),
35755
+ id: createHash8("sha256").update(canonical).digest("hex").slice(0, 12),
35373
35756
  verb: includeStateWords(include).verb
35374
35757
  };
35375
35758
  }
@@ -42212,7 +42595,7 @@ function registerWidgetsCommand(root) {
42212
42595
  }
42213
42596
 
42214
42597
  // src/commands/reconciliation.ts
42215
- import { createHash as createHash8 } from "crypto";
42598
+ import { createHash as createHash9 } from "crypto";
42216
42599
  import { promises as fs18 } from "fs";
42217
42600
 
42218
42601
  // src/api/reconciliation.ts
@@ -43839,7 +44222,7 @@ function chunkByJsonBytes(rows, maxBytes) {
43839
44222
  return chunks;
43840
44223
  }
43841
44224
  function settingsActionId(settings) {
43842
- return createHash8("sha256").update(JSON.stringify(settings)).digest("hex").slice(0, 12);
44225
+ return createHash9("sha256").update(JSON.stringify(settings)).digest("hex").slice(0, 12);
43843
44226
  }
43844
44227
  async function runReconciliationAccountsUpdateSettings(cfg) {
43845
44228
  const action = {
@@ -44217,7 +44600,7 @@ async function runReconciliationAccountsSetAssignee(cfg) {
44217
44600
  var ASSIGNEE_BATCH_CHUNK_SIZE = 100;
44218
44601
  function assigneeBatchActionId(date, rows) {
44219
44602
  const canonical = rows.map((r) => [r.accountId, r.type, r.userId, r.userName, r.userEmail]);
44220
- return createHash8("sha256").update(JSON.stringify([date, canonical])).digest("hex").slice(0, 12);
44603
+ return createHash9("sha256").update(JSON.stringify([date, canonical])).digest("hex").slice(0, 12);
44221
44604
  }
44222
44605
  async function runReconciliationAccountsSetAssigneeBatch(cfg) {
44223
44606
  const action = {
@@ -44371,7 +44754,7 @@ verify the date matches the accounts' reporting period (try \`dr reconciliation
44371
44754
  }
44372
44755
  function clearBatchActionId(date, rows) {
44373
44756
  const canonical = rows.map((r) => [r.accountId, r.type]);
44374
- return createHash8("sha256").update(JSON.stringify(["clear", date, canonical])).digest("hex").slice(0, 12);
44757
+ return createHash9("sha256").update(JSON.stringify(["clear", date, canonical])).digest("hex").slice(0, 12);
44375
44758
  }
44376
44759
  async function runReconciliationAccountsClearAssigneeBatch(cfg) {
44377
44760
  const action = {
@@ -46261,7 +46644,7 @@ function renderForError(v) {
46261
46644
  }
46262
46645
 
46263
46646
  // src/commands/mecWorkflow.ts
46264
- import { createHash as createHash9 } from "crypto";
46647
+ import { createHash as createHash10 } from "crypto";
46265
46648
 
46266
46649
  // src/api/workflow.ts
46267
46650
  function buildQueryString(params) {
@@ -48312,7 +48695,7 @@ async function runMecAccountsTaskStatuses(cfg) {
48312
48695
  renderMecAccountsTasksOrStatuses(cfg.out, resp, "statuses");
48313
48696
  }
48314
48697
  function shortHash3(input) {
48315
- return createHash9("sha256").update(input).digest("hex").slice(0, 12);
48698
+ return createHash10("sha256").update(input).digest("hex").slice(0, 12);
48316
48699
  }
48317
48700
  async function runMecWorkflowCreate(cfg) {
48318
48701
  const action = {
@@ -50696,7 +51079,7 @@ function wireMec(program) {
50696
51079
  }
50697
51080
 
50698
51081
  // src/commands/workflow.ts
50699
- import { createHash as createHash10 } from "crypto";
51082
+ import { createHash as createHash11 } from "crypto";
50700
51083
  function dash6(s) {
50701
51084
  if (s === null || s === void 0) return "-";
50702
51085
  if (typeof s === "number") return String(s);
@@ -51165,7 +51548,7 @@ function renderGroupList(out, rows) {
51165
51548
  `);
51166
51549
  }
51167
51550
  function shortHash4(input) {
51168
- return createHash10("sha256").update(input).digest("hex").slice(0, 12);
51551
+ return createHash11("sha256").update(input).digest("hex").slice(0, 12);
51169
51552
  }
51170
51553
  async function runWorkflowCreate(cfg) {
51171
51554
  const action = {
@@ -53324,7 +53707,7 @@ function wireWorkflow(root) {
53324
53707
  }
53325
53708
 
53326
53709
  // src/commands/cash.ts
53327
- import { createHash as createHash11 } from "crypto";
53710
+ import { createHash as createHash12 } from "crypto";
53328
53711
  import { promises as fs25 } from "fs";
53329
53712
 
53330
53713
  // src/api/cash.ts
@@ -54415,7 +54798,7 @@ async function runCashRulesDeleteAll(cfg) {
54415
54798
  return;
54416
54799
  }
54417
54800
  const ids = targets.map((r) => r.id).sort((a, b) => a - b);
54418
- const sha = createHash11("sha256").update(ids.join(",")).digest("hex").slice(0, 12);
54801
+ const sha = createHash12("sha256").update(ids.join(",")).digest("hex").slice(0, 12);
54419
54802
  const action = {
54420
54803
  resource: "cash-rules-batch",
54421
54804
  id: `${templateId}:${sha}`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.100",
3
+ "cliVersion": "1.6.102",
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": {
@@ -635,6 +635,22 @@
635
635
  }
636
636
  ]
637
637
  },
638
+ "folders reorder": {
639
+ "requires": [
640
+ {
641
+ "resource": "dashboards",
642
+ "action": "write"
643
+ }
644
+ ]
645
+ },
646
+ "folders move": {
647
+ "requires": [
648
+ {
649
+ "resource": "dashboards",
650
+ "action": "write"
651
+ }
652
+ ]
653
+ },
638
654
  "dynamic-ranges list": {
639
655
  "requires": [
640
656
  {
@@ -244,7 +244,10 @@ area's gotchas.
244
244
  subcommand.
245
245
 
246
246
  **Dashboards, widgets, storyboards, reports, extables** → [reference/dashboards-widgets.md](reference/dashboards-widgets.md)
247
- - `dashboards list|get|create|delete|prepare|set-date-picker`
247
+ - `dashboards list|get|create|delete|prepare|set-date-picker|set-folder`
248
+ - `folders list|create|reorder|move` — dashboard / story picker folders;
249
+ `reorder` and `move` are two-step confirm. Reorder is per-user and takes the
250
+ container's full member set (`folders list` shows it).
248
251
  - `dashboards push <id> --to <orgs> [--override]` — copy a dashboard into other
249
252
  organizations (two-step confirm). Destinations must come from
250
253
  `organizations push-targets`.
@@ -34,6 +34,29 @@ dashboard is added, with `' Copy'` appended if the name is taken.
34
34
  The push is queued and returns no job id — results arrive as in-app
35
35
  notifications. Confirm with `dashboards list --account <destination>`.
36
36
 
37
+ ### Folders (picker tree)
38
+
39
+ Gated per entity type by Unleash `dashboards__folders` / `storytelling__folders`.
40
+ Flag off: `folders list` shows no tree and says the flag is off; every write 404s.
41
+
42
+ - `folders list [--entity-type dashboard|story]` — the tree, then each
43
+ container's members in picker order as `kind:id name position` (`root` first).
44
+ This is the input for a reorder. `--json` is the raw tree only.
45
+ - `folders create --name <n> [--parent <id>]`.
46
+ - `folders reorder --container <root|folderId> (--items folder:12,entity:87,… | --move <kind:id> --to-index <n>)`
47
+ — two-step confirm. The order is **per user**, and the server takes only the
48
+ FULL set of the container's members visible to the caller, so anything partial,
49
+ foreign or duplicated is a 400. `--move` fetches the current members, splices
50
+ the item in (an index past the end lands it last) and sends them all.
51
+ `--items` is sent exactly as given, which is how you reproduce a 400; the plan
52
+ lists what is missing from the payload and what is not in the container. The
53
+ 400 body is printed verbatim.
54
+ - `folders move <folderId> --parent <root|folderId>` — two-step confirm.
55
+ Re-parents the folder and appends it at the destination's end. Admin / Data
56
+ Manager only.
57
+ - `dashboards set-folder <id> (--folder <fid> | --root)` moves a dashboard
58
+ (`PUT /api/dashboards/<id>` `{folder_id}`). It is appended at the end.
59
+
37
60
  ## Widgets
38
61
 
39
62
  - `widgets list`, `widgets get <id>`, `widgets data <id>` (alias `widget`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-cli",
3
- "version": "1.6.100",
3
+ "version": "1.6.102",
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.21",
20
+ "@datarails-ai/dr-appspace-cli": "0.10.22",
21
21
  "@modelcontextprotocol/sdk": "^1.29.0",
22
22
  "@napi-rs/keyring": "^1.3.0",
23
23
  "commander": "^12.1.0",