@datarails-ai/dr-cli 1.6.96 → 1.6.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -230,6 +230,20 @@ under one identity and run as another.
230
230
  { "apiKey": "<key>", "server": "DEV", "email": "svc@datarails.com" }
231
231
  ```
232
232
 
233
+ The key may instead live in an environment variable, with the file carrying
234
+ only the variable's **name** — the shape for CI (the key stays in the runner's
235
+ secret env, never on disk) and for agent sandboxes (where the variable's
236
+ in-sandbox value is an opaque vault placeholder substituted at egress, passed
237
+ verbatim and never validated):
238
+
239
+ ```json
240
+ { "apiKeyEnvVar": "DR_API_KEY", "server": "DEV", "email": "svc@datarails.com" }
241
+ ```
242
+
243
+ `apiKey` and `apiKeyEnvVar` are mutually exclusive — a file carrying both is
244
+ refused rather than guessed about. An unset variable fails the command with an
245
+ error naming it; the stored file is never modified by that failure.
246
+
233
247
  `email` names the stored identity — what `--account` selects and what the
234
248
  keychain entry is keyed by. It need not match the principal the key is bound to;
235
249
  the token the exchange mints is what every request authenticates as, and
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.96",
11
+ version: "1.6.98",
12
12
  description: "Datarails CLI",
13
13
  type: "module",
14
14
  engines: {
@@ -38,7 +38,7 @@ var package_default = {
38
38
  postinstall: "node scripts/postinstall.mjs"
39
39
  },
40
40
  dependencies: {
41
- "@datarails-ai/dr-appspace-cli": "0.10.20",
41
+ "@datarails-ai/dr-appspace-cli": "0.10.21",
42
42
  "@modelcontextprotocol/sdk": "^1.29.0",
43
43
  "@napi-rs/keyring": "^1.3.0",
44
44
  commander: "^12.1.0",
@@ -67,7 +67,7 @@ var package_default = {
67
67
  };
68
68
 
69
69
  // src/buildInfo.ts
70
- var BUILD_TIME = "2026-10-04T14:30:08.766Z";
70
+ var BUILD_TIME = "2026-10-05T10:25:04.884Z";
71
71
  function formatBuildTime(iso) {
72
72
  if (iso === "") return "";
73
73
  const d = new Date(iso);
@@ -151,14 +151,17 @@ function parseApiKeyExpiry(value) {
151
151
  function hasApiKey(s) {
152
152
  return s.apiKey !== void 0 && s.apiKey !== "";
153
153
  }
154
+ function hasApiKeyEnvVar(s) {
155
+ return s.apiKeyEnvVar !== void 0 && s.apiKeyEnvVar !== "";
156
+ }
154
157
  function isImpersonatedSession(s) {
155
158
  return s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "";
156
159
  }
157
160
  function isCookielessSession(s) {
158
- return isImpersonatedSession(s) || s.cookieless === true || hasApiKey(s);
161
+ return isImpersonatedSession(s) || s.cookieless === true || hasApiKey(s) || hasApiKeyEnvVar(s);
159
162
  }
160
163
  function usesApiKey(s) {
161
- return hasApiKey(s) && !isImpersonatedSession(s);
164
+ return (hasApiKey(s) || hasApiKeyEnvVar(s)) && !isImpersonatedSession(s);
162
165
  }
163
166
  var KeychainError = class extends Error {
164
167
  constructor(message, options) {
@@ -294,6 +297,16 @@ var credentialsFileSchema = z.object({
294
297
  // A user-bound API key, exchanged for `access` on demand. A file may hold
295
298
  // only this; `access` then appears after the first exchange.
296
299
  apiKey: z.string().min(1).optional(),
300
+ // DR-52657: the NAME of an environment variable holding the API key, for
301
+ // provisioners that must not put the key on disk — an agent sandbox, where
302
+ // the variable's in-sandbox value is an opaque vault placeholder
303
+ // substituted at egress, or a CI runner keeping the key in its secret env.
304
+ // The value is resolved at exchange time and passed verbatim; dr-cli never
305
+ // inspects, validates, or logs it. Mutually exclusive with `apiKey` (a
306
+ // refinement below): a file carrying both is ambiguous about which
307
+ // credential authenticates it, and silently preferring either one turns a
308
+ // provisioning mistake into commands running as the wrong key.
309
+ apiKeyEnvVar: z.string().min(1).optional(),
297
310
  // Carried verbatim, judged by `parseApiKeyExpiry` where it is read. Not
298
311
  // validated here, and not `.catch(undefined)` either: a rotation rewrites
299
312
  // the whole file from the parsed object, so a value this schema dropped
@@ -332,13 +345,19 @@ var credentialsFileSchema = z.object({
332
345
  }).passthrough().refine((c) => c.sessionId === void 0 === (c.csrfToken === void 0), {
333
346
  message: "sessionId and csrfToken must be provided together",
334
347
  path: ["sessionId"]
335
- }).refine((c) => c.access !== void 0 || c.apiKey !== void 0, {
336
- message: "one of access or apiKey is required",
348
+ }).refine((c) => !(c.apiKey !== void 0 && c.apiKeyEnvVar !== void 0), {
349
+ message: "apiKey and apiKeyEnvVar are mutually exclusive",
350
+ path: ["apiKeyEnvVar"]
351
+ }).refine((c) => c.access !== void 0 || c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0, {
352
+ message: "one of access, apiKey or apiKeyEnvVar is required",
337
353
  path: ["access"]
338
- }).refine((c) => c.apiKey !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0, {
339
- message: "refresh is required unless an apiKey or a session cookie is present",
340
- path: ["refresh"]
341
- });
354
+ }).refine(
355
+ (c) => c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0,
356
+ {
357
+ message: "refresh is required unless an apiKey, apiKeyEnvVar or a session cookie is present",
358
+ path: ["refresh"]
359
+ }
360
+ );
342
361
  function credentialsFilePath() {
343
362
  const override = process.env[CREDENTIALS_FILE_ENV];
344
363
  return override !== void 0 && override !== "" ? override : DEFAULT_CREDENTIALS_FILE;
@@ -801,7 +820,13 @@ async function exchangeApiKey(host, apiKey, options) {
801
820
  if (key === "") {
802
821
  throw new Error("api-key exchange: the stored API key is empty");
803
822
  }
804
- if (!/^[\x21-\x7e]+$/.test(key)) {
823
+ if (options?.opaque === true) {
824
+ if (/[\r\n\0]/.test(key) || !/^[\x00-\xff]+$/.test(key)) {
825
+ throw new Error(
826
+ "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)"
827
+ );
828
+ }
829
+ } else if (!/^[\x21-\x7e]+$/.test(key)) {
805
830
  throw new Error(
806
831
  "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
832
  );
@@ -965,10 +990,31 @@ function mintedByApiKey(token) {
965
990
  function apiKeyFingerprint(apiKey) {
966
991
  return createHash("sha256").update(apiKey.trim()).digest("hex").slice(0, 32);
967
992
  }
993
+ function resolveApiKeyMaterial(session) {
994
+ if (session.apiKey !== void 0 && session.apiKey !== "") {
995
+ return { key: session.apiKey, fromEnv: false };
996
+ }
997
+ const name = session.apiKeyEnvVar;
998
+ if (name === void 0 || name === "") {
999
+ throw new Error("api-key exchange: the session carries no API key");
1000
+ }
1001
+ const value = process.env[name];
1002
+ if (value === void 0 || value === "") {
1003
+ throw new Error(
1004
+ `api-key exchange: the credential file names environment variable ${name} as the API-key carrier, but it is unset or empty in this environment`
1005
+ );
1006
+ }
1007
+ return { key: value, fromEnv: true };
1008
+ }
968
1009
  function mintedByStoredApiKey(session) {
969
1010
  if (!mintedByApiKey(session.jwtAccess)) return false;
970
- if (session.apiKey === void 0 || session.apiKey === "") return false;
971
- return session.jwtApiKeyFingerprint === apiKeyFingerprint(session.apiKey);
1011
+ let material;
1012
+ try {
1013
+ material = resolveApiKeyMaterial(session);
1014
+ } catch {
1015
+ return false;
1016
+ }
1017
+ return session.jwtApiKeyFingerprint === apiKeyFingerprint(material.key);
972
1018
  }
973
1019
  function apiKeyStaleBuffer(session, identity2, fallbackMs) {
974
1020
  if (session.jwtAccess === void 0 || session.jwtAccess === "") return fallbackMs;
@@ -995,12 +1041,13 @@ function ttlOf(token) {
995
1041
  function exchangeForSession(store, serverKey, email, session, host, opts) {
996
1042
  const identity2 = `${serverKey}:${email}`;
997
1043
  if (opts.signal?.aborted === true) return Promise.reject(opts.signal.reason);
998
- const key = JSON.stringify([
999
- identity2,
1000
- host,
1001
- opts.force,
1002
- apiKeyFingerprint(session.apiKey)
1003
- ]);
1044
+ let material;
1045
+ try {
1046
+ material = resolveApiKeyMaterial(session);
1047
+ } catch (err) {
1048
+ throw new SessionExpiredError(`${identity2} (${msgOf3(err)})`);
1049
+ }
1050
+ const key = JSON.stringify([identity2, host, opts.force, apiKeyFingerprint(material.key)]);
1004
1051
  const pending = inFlightExchanges.get(key);
1005
1052
  if (pending !== void 0) return joinExchange(pending, opts.signal);
1006
1053
  const controller = new AbortController();
@@ -1015,7 +1062,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1015
1062
  for (; ; ) {
1016
1063
  let tok;
1017
1064
  try {
1018
- tok = await exchangeApiKey(host, session.apiKey, { signal });
1065
+ tok = await exchangeApiKey(host, material.key, { signal, opaque: material.fromEnv });
1019
1066
  } catch (err) {
1020
1067
  const cachedIsUsable = !opts.force && mintedByStoredApiKey(session) && !jwtExpiringSoon(session, 0);
1021
1068
  if (!(err instanceof ApiKeyExchangeError)) {
@@ -1054,7 +1101,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1054
1101
  // the token did not come from. Read back by `mintedByStoredApiKey` on
1055
1102
  // every later command to tell this token apart from one the previous
1056
1103
  // key minted.
1057
- jwtApiKeyFingerprint: apiKeyFingerprint(session.apiKey),
1104
+ jwtApiKeyFingerprint: apiKeyFingerprint(material.key),
1058
1105
  // Dropped, not carried: the exchange mints no refresh token, and the
1059
1106
  // refresh rung is closed to a keyed identity by contract. Clearing it
1060
1107
  // is what lets "keyed, and still holding a refresh token" mean "these
@@ -1065,7 +1112,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1065
1112
  };
1066
1113
  try {
1067
1114
  const current = await store.getSession(serverKey, email);
1068
- if (current === void 0 || current.apiKey !== session.apiKey) {
1115
+ if (current === void 0 || current.apiKey !== session.apiKey || current.apiKeyEnvVar !== session.apiKeyEnvVar) {
1069
1116
  return next;
1070
1117
  }
1071
1118
  await store.saveSession(serverKey, next);
@@ -1383,7 +1430,8 @@ var KeyringStore = class _KeyringStore {
1383
1430
  const impersonated = impersonatorEmail !== "";
1384
1431
  const cookielessFlag = parsed2.cookieless === true;
1385
1432
  const apiKey = await this.backend.get(SERVICE, `${sessionAccount}:apikey`);
1386
- const keyPresent = apiKey !== void 0 && apiKey !== "";
1433
+ const apiKeyEnvVar = typeof parsed2.apiKeyEnvVar === "string" ? parsed2.apiKeyEnvVar : "";
1434
+ const keyPresent = apiKey !== void 0 && apiKey !== "" || apiKeyEnvVar !== "";
1387
1435
  const cookieless = impersonated || cookielessFlag || keyPresent;
1388
1436
  const halfCookie = sessionId === "" !== (csrfToken === "");
1389
1437
  if (savedAtStr === "" || halfCookie || !cookieless && sessionId === "") {
@@ -1429,9 +1477,12 @@ var KeyringStore = class _KeyringStore {
1429
1477
  if (jwtRefresh !== void 0 && jwtRefresh !== "") {
1430
1478
  session.jwtRefresh = jwtRefresh;
1431
1479
  }
1432
- if (keyPresent) {
1480
+ if (apiKey !== void 0 && apiKey !== "") {
1433
1481
  session.apiKey = apiKey;
1434
1482
  }
1483
+ if (apiKeyEnvVar !== "") {
1484
+ session.apiKeyEnvVar = apiKeyEnvVar;
1485
+ }
1435
1486
  return session;
1436
1487
  }
1437
1488
  /**
@@ -1463,9 +1514,9 @@ var KeyringStore = class _KeyringStore {
1463
1514
  const impersonated = isImpersonatedSession(session);
1464
1515
  if (isCookielessSession(session)) {
1465
1516
  const hasJwt = session.jwtAccess !== void 0 && session.jwtAccess !== "";
1466
- if (!hasJwt && !hasApiKey(session)) {
1517
+ if (!hasJwt && !hasApiKey(session) && !hasApiKeyEnvVar(session)) {
1467
1518
  throw new Error(
1468
- "saveSession: cookie-less session must have a jwtAccess token or an apiKey"
1519
+ "saveSession: cookie-less session must have a jwtAccess token, an apiKey, or an apiKeyEnvVar"
1469
1520
  );
1470
1521
  }
1471
1522
  if (session.sessionId === "" !== (session.csrfToken === "")) {
@@ -1496,6 +1547,9 @@ var KeyringStore = class _KeyringStore {
1496
1547
  if (session.jwtApiKeyFingerprint !== void 0 && session.jwtApiKeyFingerprint !== "") {
1497
1548
  metaBlob.jwtApiKeyFingerprint = session.jwtApiKeyFingerprint;
1498
1549
  }
1550
+ if (hasApiKeyEnvVar(session)) {
1551
+ metaBlob.apiKeyEnvVar = session.apiKeyEnvVar;
1552
+ }
1499
1553
  if (impersonated) {
1500
1554
  metaBlob.impersonatorEmail = session.impersonatorEmail;
1501
1555
  if (session.impersonatedUserId !== void 0) {
@@ -2599,7 +2653,9 @@ var FileKeyringBackend = class _FileKeyringBackend {
2599
2653
  // Named as the Session field, since the blob is what `getSession` reads.
2600
2654
  ...creds.accessApiKeyFingerprint !== void 0 && {
2601
2655
  jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
2602
- }
2656
+ },
2657
+ // The env var's NAME only — the resolved key never enters any store.
2658
+ ...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar }
2603
2659
  });
2604
2660
  }
2605
2661
  static slotOf(creds, account) {
@@ -2627,11 +2683,16 @@ var FileKeyringBackend = class _FileKeyringBackend {
2627
2683
  const csrfToken = typeof parsed2.csrfToken === "string" ? parsed2.csrfToken : "";
2628
2684
  const apiKeyExpiresAt = typeof parsed2.apiKeyExpiresAt === "string" ? parsed2.apiKeyExpiresAt : "";
2629
2685
  const accessApiKeyFingerprint = typeof parsed2.jwtApiKeyFingerprint === "string" ? parsed2.jwtApiKeyFingerprint : "";
2686
+ const apiKeyEnvVar = typeof parsed2.apiKeyEnvVar === "string" ? parsed2.apiKeyEnvVar : "";
2630
2687
  return {
2631
2688
  ...sessionId !== "" && { sessionId },
2632
2689
  ...csrfToken !== "" && { csrfToken },
2633
2690
  ...apiKeyExpiresAt !== "" && { apiKeyExpiresAt },
2634
- ...accessApiKeyFingerprint !== "" && { accessApiKeyFingerprint }
2691
+ ...accessApiKeyFingerprint !== "" && { accessApiKeyFingerprint },
2692
+ // Set-if-present, never deleted on a blob that lost it (the
2693
+ // `apiKeyExpiresAt` rule): it is the identity's only credential pointer,
2694
+ // and a meta rewrite must not be able to brick the file.
2695
+ ...apiKeyEnvVar !== "" && { apiKeyEnvVar }
2635
2696
  };
2636
2697
  }
2637
2698
  async get(_service, account) {
@@ -2724,6 +2785,7 @@ var FileKeyringBackend = class _FileKeyringBackend {
2724
2785
  if (meta.apiKeyExpiresAt !== void 0) next.apiKeyExpiresAt = meta.apiKeyExpiresAt;
2725
2786
  if (meta.accessApiKeyFingerprint === void 0) delete next.accessApiKeyFingerprint;
2726
2787
  else next.accessApiKeyFingerprint = meta.accessApiKeyFingerprint;
2788
+ if (meta.apiKeyEnvVar !== void 0) next.apiKeyEnvVar = meta.apiKeyEnvVar;
2727
2789
  break;
2728
2790
  }
2729
2791
  }
@@ -3624,6 +3686,10 @@ var PROD_GATE_TIER_B = /* @__PURE__ */ new Set([
3624
3686
  // tier so a CSM or agent can run a MEC implementation's bulk assignment on
3625
3687
  // prod (DR-54064).
3626
3688
  "recon-member-batch|set",
3689
+ // clear-assignee --file: N `recon-member|clear` writes under one token, same
3690
+ // reasoning — each is undone by set-assignee, already-empty slots are
3691
+ // skipped, and the plan shows the sign-offs it resets (DR-54260).
3692
+ "recon-member-batch|clear",
3627
3693
  "recon-member|clear",
3628
3694
  "recon-member|set-due-date",
3629
3695
  "recon-member|sign",
@@ -42254,31 +42320,51 @@ function validateRow(row, idx) {
42254
42320
 
42255
42321
  // src/commands/_reconciliationAssigneeCsv.ts
42256
42322
  import { promises as fs17 } from "fs";
42257
- var REQUIRED_COLUMNS = ["account_id", "type", "user_id"];
42258
- var OPTIONAL_COLUMNS = ["user_name", "user_email"];
42259
- var KNOWN_COLUMNS = /* @__PURE__ */ new Set([...REQUIRED_COLUMNS, ...OPTIONAL_COLUMNS]);
42323
+ var ASSIGNEE_REQUIRED = ["account_id", "type", "user_id"];
42324
+ var ASSIGNEE_OPTIONAL = ["user_name", "user_email"];
42325
+ var CLEAR_REQUIRED = ["account_id", "type"];
42260
42326
  async function readAssigneeCsv(path11) {
42261
- let raw;
42327
+ return parseAssigneeCsvText(await readFileArg(path11));
42328
+ }
42329
+ async function readClearAssigneeCsv(path11) {
42330
+ return parseClearAssigneeCsvText(await readFileArg(path11));
42331
+ }
42332
+ async function readFileArg(path11) {
42262
42333
  try {
42263
- raw = await fs17.readFile(path11, "utf8");
42334
+ return await fs17.readFile(path11, "utf8");
42264
42335
  } catch (err) {
42265
42336
  const m = err instanceof Error ? err.message : String(err);
42266
42337
  throw new Error(`read --file: ${m}`);
42267
42338
  }
42268
- return parseAssigneeCsvText(raw);
42269
42339
  }
42270
42340
  function parseAssigneeCsvText(text2) {
42341
+ return parseSlotRows(text2, ASSIGNEE_REQUIRED, ASSIGNEE_OPTIONAL, (slot, cell) => {
42342
+ const rawUserId = cell("user_id");
42343
+ const userId = Number(rawUserId);
42344
+ if (!/^\d+$/.test(rawUserId) || !Number.isSafeInteger(userId) || userId <= 0) {
42345
+ throw new Error(
42346
+ `--file: line ${slot.lineNo}: invalid user_id "${rawUserId}" (expected positive integer)`
42347
+ );
42348
+ }
42349
+ return { ...slot, userId, userName: cell("user_name"), userEmail: cell("user_email") };
42350
+ });
42351
+ }
42352
+ function parseClearAssigneeCsvText(text2) {
42353
+ return parseSlotRows(text2, CLEAR_REQUIRED, [], (slot) => slot);
42354
+ }
42355
+ function parseSlotRows(text2, required2, optional, extra) {
42271
42356
  const withoutBom = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
42272
42357
  const rows = parseCsvRows(withoutBom);
42273
42358
  if (rows.length === 0) {
42274
42359
  throw new Error("--file: CSV is empty");
42275
42360
  }
42361
+ const known = /* @__PURE__ */ new Set([...required2, ...optional]);
42276
42362
  const header = rows[0].map((h) => h.trim());
42277
42363
  const col = /* @__PURE__ */ new Map();
42278
42364
  header.forEach((h, i) => {
42279
- if (!KNOWN_COLUMNS.has(h)) {
42365
+ if (!known.has(h)) {
42280
42366
  throw new Error(
42281
- `--file: unknown CSV column "${h}" \u2014 expected ${[...REQUIRED_COLUMNS, ...OPTIONAL_COLUMNS].join(",")}`
42367
+ `--file: unknown CSV column "${h}" \u2014 expected ${[...required2, ...optional].join(",")}`
42282
42368
  );
42283
42369
  }
42284
42370
  if (col.has(h)) {
@@ -42286,7 +42372,7 @@ function parseAssigneeCsvText(text2) {
42286
42372
  }
42287
42373
  col.set(h, i);
42288
42374
  });
42289
- for (const c of REQUIRED_COLUMNS) {
42375
+ for (const c of required2) {
42290
42376
  if (!col.has(c)) {
42291
42377
  throw new Error(`--file: CSV header is missing required column "${c}"`);
42292
42378
  }
@@ -42295,30 +42381,24 @@ function parseAssigneeCsvText(text2) {
42295
42381
  if (dataRows.length === 0) {
42296
42382
  throw new Error("--file: CSV has no data rows (header only)");
42297
42383
  }
42298
- const cell = (cells, name) => {
42299
- const i = col.get(name);
42300
- return i === void 0 ? "" : (cells[i] ?? "").trim();
42301
- };
42302
42384
  const seenSlots = /* @__PURE__ */ new Map();
42303
42385
  const out = [];
42304
42386
  for (const { cells, lineNo } of dataRows) {
42305
- const accountId = cell(cells, "account_id");
42387
+ const cell = (name) => {
42388
+ const i = col.get(name);
42389
+ return i === void 0 ? "" : (cells[i] ?? "").trim();
42390
+ };
42391
+ const accountId = cell("account_id");
42306
42392
  if (accountId === "") {
42307
42393
  throw new Error(`--file: line ${lineNo}: "account_id" is required`);
42308
42394
  }
42309
- const rawType = cell(cells, "type");
42395
+ const rawType = cell("type");
42310
42396
  if (rawType !== "Preparer" && rawType !== "Reviewer") {
42311
42397
  throw new Error(
42312
42398
  `--file: line ${lineNo}: invalid type "${rawType}" (expected "Preparer" or "Reviewer")`
42313
42399
  );
42314
42400
  }
42315
- const rawUserId = cell(cells, "user_id");
42316
- const userId = Number(rawUserId);
42317
- if (!/^\d+$/.test(rawUserId) || !Number.isSafeInteger(userId) || userId <= 0) {
42318
- throw new Error(
42319
- `--file: line ${lineNo}: invalid user_id "${rawUserId}" (expected positive integer)`
42320
- );
42321
- }
42401
+ const row = extra({ lineNo, accountId, type: rawType }, cell);
42322
42402
  const slot = `${accountId}:${rawType}`;
42323
42403
  const prior = seenSlots.get(slot);
42324
42404
  if (prior !== void 0) {
@@ -42327,14 +42407,7 @@ function parseAssigneeCsvText(text2) {
42327
42407
  );
42328
42408
  }
42329
42409
  seenSlots.set(slot, lineNo);
42330
- out.push({
42331
- lineNo,
42332
- accountId,
42333
- type: rawType,
42334
- userId,
42335
- userName: cell(cells, "user_name"),
42336
- userEmail: cell(cells, "user_email")
42337
- });
42410
+ out.push(row);
42338
42411
  }
42339
42412
  return out;
42340
42413
  }
@@ -44025,52 +44098,186 @@ verify the date matches the accounts' reporting period (try \`dr reconciliation
44025
44098
  account_id: id,
44026
44099
  members: applyAssignments(byId.get(id), changesByAccount.get(id))
44027
44100
  }));
44028
- extendForBatch(cfg.extendTimeout, requests * 2 - 1);
44029
- let applied = 0;
44030
- for (let i = 0; i < items.length; i += chunkSize) {
44031
- const chunk = items.slice(i, i + chunkSize);
44032
- if (i > 0) {
44033
- let drift;
44034
- try {
44035
- drift = await chunkDrift(cfg, byId, chunk);
44036
- } catch (err) {
44037
- const msg = err instanceof Error ? err.message : String(err);
44038
- throw new Error(
44039
- `re-read failed after ${applied} of ${items.length} account${plural(items.length)} were written; nothing more was sent: ${msg}
44040
- Re-run the same command without --confirm-token: rows that landed plan as unchanged, so the new plan covers only the rest.`
44041
- );
44042
- }
44043
- if (drift.length > 0) {
44044
- throw new Error(
44045
- `halted after ${applied} of ${items.length} account${plural(items.length)} were written; nothing more was sent:
44046
- ` + drift.join("\n ") + `
44047
- The tenant changed since the plan. Re-run the same command without --confirm-token: rows that landed plan as unchanged. If accounts are gone, the trial balance was rebuilt and their ids changed \u2014 wait for it to settle and rebuild the file from \`dr reconciliation accounts list\`.`
44101
+ const applied = await writeMemberChunks(cfg, byId, items, chunkSize);
44102
+ if (cfg.asJson) {
44103
+ const changed = accountIds.flatMap((id) => changesByAccount.get(id).map(toJsonRow));
44104
+ writeJSONIndented(cfg.out, { changed, unchanged: unchanged.map(toJsonRow) });
44105
+ return;
44106
+ }
44107
+ cfg.out.write(
44108
+ `Set ${changeCount} role${plural(changeCount)} on ${applied} account${plural(applied)} (${unchanged.length} unchanged).
44109
+ `
44110
+ );
44111
+ }
44112
+ });
44113
+ }
44114
+ function clearBatchActionId(date, rows) {
44115
+ const canonical = rows.map((r) => [r.accountId, r.type]);
44116
+ return createHash8("sha256").update(JSON.stringify(["clear", date, canonical])).digest("hex").slice(0, 12);
44117
+ }
44118
+ async function runReconciliationAccountsClearAssigneeBatch(cfg) {
44119
+ const action = {
44120
+ resource: "recon-member-batch",
44121
+ id: clearBatchActionId(cfg.date, cfg.rows),
44122
+ verb: "clear"
44123
+ };
44124
+ await preflightConfirmToken(action, cfg.token);
44125
+ extendForBatch(cfg.extendTimeout, 1);
44126
+ const accounts = await getAccounts(
44127
+ cfg.client,
44128
+ { date: cfg.date },
44129
+ { signal: cfg.signal }
44130
+ );
44131
+ const byId = new Map(accounts.map((a) => [a.account_id, a]));
44132
+ const problems = [];
44133
+ for (const row of cfg.rows) {
44134
+ const account = byId.get(row.accountId);
44135
+ if (account === void 0) {
44136
+ problems.push(`line ${row.lineNo}: account "${row.accountId}" not found at --date`);
44137
+ } else if (account.members.length > 0 && !account.members.some((m) => m.type === row.type)) {
44138
+ problems.push(
44139
+ `line ${row.lineNo}: account "${row.accountId}" has a partial member set (${account.members.map((m) => m.type).join(", ")}) and is missing ${row.type}`
44140
+ );
44141
+ }
44142
+ }
44143
+ if (problems.length > 0) {
44144
+ const shown = problems.slice(0, 10);
44145
+ const more = problems.length > 10 ? [`\u2026 (+${problems.length - 10} more)`] : [];
44146
+ throw new Error(
44147
+ `--file: ${problems.length} row${plural(problems.length)} cannot be applied; nothing was written:
44148
+ ` + [...shown, ...more].join("\n ") + `
44149
+ verify the date matches the accounts' reporting period (try \`dr reconciliation accounts list --date \u2026\`)`
44150
+ );
44151
+ }
44152
+ const clearsByAccount = /* @__PURE__ */ new Map();
44153
+ const unchanged = [];
44154
+ for (const row of cfg.rows) {
44155
+ const current = byId.get(row.accountId).members.find((m) => m.type === row.type);
44156
+ if (current === void 0 || current.user_id === 0) {
44157
+ unchanged.push(row);
44158
+ continue;
44159
+ }
44160
+ const list28 = clearsByAccount.get(row.accountId) ?? [];
44161
+ list28.push({ row, current });
44162
+ clearsByAccount.set(row.accountId, list28);
44163
+ }
44164
+ const clearCount = cfg.rows.length - unchanged.length;
44165
+ const n = cfg.rows.length;
44166
+ if (clearCount === 0) {
44167
+ if (cfg.asJson) {
44168
+ writeJSONIndented(cfg.out, {
44169
+ changed: [],
44170
+ unchanged: unchanged.map((r) => ({ account_id: r.accountId, type: r.type }))
44171
+ });
44172
+ return;
44173
+ }
44174
+ cfg.out.write(
44175
+ `Nothing to clear: all ${n} row${plural(n)} name a slot that is already Unassigned or does not exist yet.
44176
+ `
44177
+ );
44178
+ return;
44179
+ }
44180
+ const chunkSize = cfg.chunkSize ?? ASSIGNEE_BATCH_CHUNK_SIZE;
44181
+ const accountIds = [...clearsByAccount.keys()];
44182
+ const requests = Math.ceil(accountIds.length / chunkSize);
44183
+ await twoStepConfirm({
44184
+ out: cfg.out,
44185
+ action,
44186
+ token: cfg.token,
44187
+ server: cfg.serverFlag,
44188
+ confirmCmd: cfg.confirmCmd,
44189
+ describe: async () => {
44190
+ let signOffsReset = 0;
44191
+ const preview = [];
44192
+ for (const id of accountIds) {
44193
+ const account = byId.get(id);
44194
+ for (const { row, current } of clearsByAccount.get(id)) {
44195
+ if (current.is_sign_off) signOffsReset += 1;
44196
+ if (preview.length < 5) {
44197
+ preview.push(
44198
+ ` ${account.account_name} (${id}) ${row.type}: ${current.name} (user ${current.user_id}) \u2192 Unassigned`
44048
44199
  );
44049
44200
  }
44050
44201
  }
44051
- try {
44052
- await updateAccountMembers(cfg.client, chunk, { signal: cfg.signal });
44053
- } catch (err) {
44054
- const msg = err instanceof Error ? err.message : String(err);
44055
- throw new Error(
44056
- `PATCH failed after ${applied} of ${items.length} account${plural(items.length)} were written (accounts ${i + 1}\u2013${i + chunk.length} may be partially applied): ${msg}
44057
- Re-run the same command without --confirm-token: rows that landed plan as unchanged, so the new plan covers only the rest.`
44058
- );
44059
- }
44060
- applied += chunk.length;
44061
44202
  }
44203
+ const lines = [
44204
+ `rows: ${n}`,
44205
+ `to clear: ${clearCount} role${plural(clearCount)} on ${accountIds.length} account${plural(accountIds.length)} (${requests} PATCH request${plural(requests)})`,
44206
+ `unchanged: ${unchanged.length} (slot already Unassigned, or the account has no member slots \u2014 skipped)`,
44207
+ `sign-offs reset: ${signOffsReset}`,
44208
+ "changes:",
44209
+ ...preview
44210
+ ];
44211
+ if (clearCount > preview.length) lines.push(` \u2026 (+${clearCount - preview.length} more)`);
44212
+ return lines;
44213
+ },
44214
+ execute: async () => {
44215
+ const items = accountIds.map((id) => {
44216
+ const cleared = new Set(clearsByAccount.get(id).map((c) => c.row.type));
44217
+ return {
44218
+ account_id: id,
44219
+ members: byId.get(id).members.map(
44220
+ (m) => cleared.has(m.type) ? unassignedMember(m.type, m.due_date) : memberSummaryToWire(m)
44221
+ )
44222
+ };
44223
+ });
44224
+ const applied = await writeMemberChunks(cfg, byId, items, chunkSize);
44062
44225
  if (cfg.asJson) {
44063
- const changed = accountIds.flatMap((id) => changesByAccount.get(id).map(toJsonRow));
44064
- writeJSONIndented(cfg.out, { changed, unchanged: unchanged.map(toJsonRow) });
44226
+ writeJSONIndented(cfg.out, {
44227
+ changed: accountIds.flatMap(
44228
+ (id) => clearsByAccount.get(id).map(({ row, current }) => ({
44229
+ account_id: row.accountId,
44230
+ type: row.type,
44231
+ previous_user_id: current.user_id
44232
+ }))
44233
+ ),
44234
+ unchanged: unchanged.map((r) => ({ account_id: r.accountId, type: r.type }))
44235
+ });
44065
44236
  return;
44066
44237
  }
44067
44238
  cfg.out.write(
44068
- `Set ${changeCount} role${plural(changeCount)} on ${applied} account${plural(applied)} (${unchanged.length} unchanged).
44239
+ `Cleared ${clearCount} role${plural(clearCount)} on ${applied} account${plural(applied)} (${unchanged.length} unchanged).
44069
44240
  `
44070
44241
  );
44071
44242
  }
44072
44243
  });
44073
44244
  }
44245
+ async function writeMemberChunks(ctx, planned, items, chunkSize) {
44246
+ const requests = Math.ceil(items.length / chunkSize);
44247
+ extendForBatch(ctx.extendTimeout, requests * 2);
44248
+ let applied = 0;
44249
+ for (let i = 0; i < items.length; i += chunkSize) {
44250
+ const chunk = items.slice(i, i + chunkSize);
44251
+ let drift;
44252
+ try {
44253
+ drift = await chunkDrift(ctx, planned, chunk);
44254
+ } catch (err) {
44255
+ const msg = err instanceof Error ? err.message : String(err);
44256
+ throw new Error(
44257
+ `re-read failed after ${applied} of ${items.length} account${plural(items.length)} were written; nothing more was sent: ${msg}
44258
+ Re-run the same command without --confirm-token: rows that landed plan as unchanged, so the new plan covers only the rest.`
44259
+ );
44260
+ }
44261
+ if (drift.length > 0) {
44262
+ throw new Error(
44263
+ `halted after ${applied} of ${items.length} account${plural(items.length)} were written; nothing more was sent:
44264
+ ` + drift.join("\n ") + `
44265
+ The tenant changed since the plan. Re-run the same command without --confirm-token: rows that landed plan as unchanged. If accounts are gone, the trial balance was rebuilt and their ids changed \u2014 wait for it to settle and rebuild the file from \`dr reconciliation accounts list\`.`
44266
+ );
44267
+ }
44268
+ try {
44269
+ await updateAccountMembers(ctx.client, chunk, { signal: ctx.signal });
44270
+ } catch (err) {
44271
+ const msg = err instanceof Error ? err.message : String(err);
44272
+ throw new Error(
44273
+ `PATCH failed after ${applied} of ${items.length} account${plural(items.length)} were written (accounts ${i + 1}\u2013${i + chunk.length} may be partially applied): ${msg}
44274
+ Re-run the same command without --confirm-token: rows that landed plan as unchanged, so the new plan covers only the rest.`
44275
+ );
44276
+ }
44277
+ applied += chunk.length;
44278
+ }
44279
+ return applied;
44280
+ }
44074
44281
  async function chunkDrift(cfg, planned, chunk) {
44075
44282
  const now = await getAccounts(cfg.client, { date: cfg.date }, { signal: cfg.signal });
44076
44283
  const nowById = new Map(now.map((a) => [a.account_id, a]));
@@ -44579,16 +44786,69 @@ function wireReconciliation(program) {
44579
44786
  );
44580
44787
  addServerFlag(setAssigneeCmd);
44581
44788
  addAccountFlag(setAssigneeCmd);
44582
- const clearAssigneeCmd = accounts.command("clear-assignee").description(
44583
- "Clear the Preparer or Reviewer slot (sets user_id=0 + Unassigned name/email; preserves due_date, resets sign-off)"
44584
- ).argument("<account_uuid>", "account uuid").requiredOption("--date <YYYY-MM-DD>", "Reporting date").requiredOption("--type <s>", "Preparer | Reviewer (case-sensitive)").option("--json", "Output the cleared member record as JSON instead of a confirmation", false).option(
44789
+ const clearAssigneeCmd = accounts.command("clear-assignee").summary("Clear the Preparer or Reviewer slot of one account, or many with --file").description(
44790
+ [
44791
+ "Clear the Preparer or Reviewer slot (sets user_id=0 + Unassigned name/email; preserves due_date, resets sign-off)",
44792
+ "",
44793
+ "Bulk form \u2014 one plan and one token for many slots:",
44794
+ " dr reconciliation accounts clear-assignee --file slots.csv --date 2025-04-30",
44795
+ " # CSV header: account_id,type \u2014 one row per (account, role)"
44796
+ ].join("\n")
44797
+ ).argument("[account_uuid]", "account uuid; omit with --file").requiredOption("--date <YYYY-MM-DD>", "Reporting date").option("--type <s>", "Preparer | Reviewer (case-sensitive); required without --file").option(
44798
+ "--file <path>",
44799
+ "CSV of slots to clear (header account_id,type); one plan + one token for the whole file"
44800
+ ).option(
44801
+ "--json",
44802
+ "Output the cleared member record(s) as JSON instead of a confirmation",
44803
+ false
44804
+ ).option("--timeout <seconds>", `With --file: ${BATCH_TIMEOUT_FLAG_HELP}`).option(
44585
44805
  "--confirm-token <token>",
44586
44806
  "Token from a prior plan run \u2014 required to actually clear",
44587
44807
  ""
44588
44808
  ).action(
44589
44809
  async (accountUuid, opts) => {
44590
44810
  const date = parseDateFlag(opts.date ?? "", "--date");
44591
- const type = parseMemberTypeFlag(opts.type ?? "", "--type");
44811
+ if (opts.file !== void 0) {
44812
+ const conflicting = [
44813
+ accountUuid !== void 0 ? "<account_uuid>" : "",
44814
+ opts.type !== void 0 ? "--type" : ""
44815
+ ].filter((s) => s !== "");
44816
+ if (conflicting.length > 0) {
44817
+ throw new Error(
44818
+ `--file cannot be combined with ${conflicting.join(", ")} (the CSV carries them per row)`
44819
+ );
44820
+ }
44821
+ const rows = await readClearAssigneeCsv(opts.file);
44822
+ await preflightConfirmToken(
44823
+ { resource: "recon-member-batch", id: clearBatchActionId(date, rows), verb: "clear" },
44824
+ opts.confirmToken
44825
+ );
44826
+ const serverKey2 = resolveServer(opts.server);
44827
+ const confirmCmd2 = `dr reconciliation accounts clear-assignee --file ${JSON.stringify(opts.file)} --date ${opts.date}` + (opts.json === true ? " --json" : "") + (opts.timeout !== void 0 ? ` --timeout ${opts.timeout}` : "") + ` --server ${serverKey2}`;
44828
+ const setup2 = await setupCall(opts.server, opts.account, batchSetupOptions(opts.timeout));
44829
+ try {
44830
+ await runReconciliationAccountsClearAssigneeBatch({
44831
+ client: setup2.client,
44832
+ extendTimeout: setup2.extendTimeout,
44833
+ date,
44834
+ rows,
44835
+ token: opts.confirmToken,
44836
+ confirmCmd: confirmCmd2,
44837
+ serverFlag: serverKey2,
44838
+ asJson: opts.json === true,
44839
+ signal: setup2.signal,
44840
+ out: process.stdout
44841
+ });
44842
+ } finally {
44843
+ setup2.cleanup();
44844
+ }
44845
+ return;
44846
+ }
44847
+ if (accountUuid === void 0) {
44848
+ throw new Error("missing <account_uuid> (or pass --file <csv> for many slots)");
44849
+ }
44850
+ if (opts.type === void 0) throw new Error("required option '--type <s>' not specified");
44851
+ const type = parseMemberTypeFlag(opts.type, "--type");
44592
44852
  await preflightConfirmToken(
44593
44853
  {
44594
44854
  resource: "recon-member",
@@ -44749,7 +45009,7 @@ import * as nodePath2 from "path";
44749
45009
  var API_PREFIX = "/api/v1";
44750
45010
  var OPERATOR_EMAIL_HEADER = "X-Operator-Email";
44751
45011
  function withOperatorAttribution(client, options) {
44752
- const operatorEmail = isImpersonatedSession(client.session) ? client.session.impersonatorEmail : client.session.cookieless === true && !hasApiKey(client.session) && client.session.interactiveLogin !== true ? void 0 : client.email;
45012
+ 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;
44753
45013
  if (operatorEmail === void 0) return options ?? {};
44754
45014
  return {
44755
45015
  ...options,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.96",
3
+ "cliVersion": "1.6.98",
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": {
@@ -45,8 +45,8 @@ Account assignment / sign-off (all take the bank-account UUID via
45
45
  resets. Every account is checked before anything is written, and the
46
46
  write goes out in PATCHes of 100 accounts. If one fails, the error says
47
47
  how many accounts landed; re-run the same file without a token and the
48
- new plan covers only the rest. Before each PATCH after the first, the
49
- accounts are re-read; if any account in the next chunk is gone (a
48
+ new plan covers only the rest. Before each PATCH, the accounts are
49
+ re-read; if any account in the next chunk is gone (a
50
50
  trial-balance rebuild re-mints account ids) or its member slots changed
51
51
  since the plan, the run halts, says how many accounts landed, and names
52
52
  the drifted accounts. Gone accounts mean the file must be rebuilt from
@@ -55,6 +55,13 @@ Account assignment / sign-off (all take the bank-account UUID via
55
55
  - `reconciliation accounts clear-assignee <account_uuid> --date <YYYY-MM-DD>
56
56
  --type Preparer|Reviewer` — unassigns (preserves due_date, resets sign-off).
57
57
  Two-step confirm.
58
+ - `reconciliation accounts clear-assignee --file <csv> --date <YYYY-MM-DD>` —
59
+ the bulk form. Header `account_id,type`, one row per (account, role); the
60
+ user columns of a set-assignee file are rejected, so a set-assignee
61
+ manifest cannot be passed here by mistake. One plan and one token cover the
62
+ whole file. Slots already Unassigned, and accounts with no member slots,
63
+ are skipped. Same 100-account PATCHes, per-chunk re-check, and resume
64
+ behaviour as `set-assignee --file`.
58
65
  - `reconciliation accounts set-due-date <account_uuid> --date <YYYY-MM-DD>
59
66
  --type Preparer|Reviewer (--due-date <YYYY-MM-DD> | --epoch <n> | --clear)`.
60
67
  Two-step confirm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-cli",
3
- "version": "1.6.96",
3
+ "version": "1.6.98",
4
4
  "description": "Datarails CLI",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@datarails-ai/dr-appspace-cli": "0.10.20",
20
+ "@datarails-ai/dr-appspace-cli": "0.10.21",
21
21
  "@modelcontextprotocol/sdk": "^1.29.0",
22
22
  "@napi-rs/keyring": "^1.3.0",
23
23
  "commander": "^12.1.0",