@datarails-ai/dr-cli 1.6.99 → 1.6.101

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.
175
-
176
- ```
177
- DR_OAUTH_FLOW=v2 dr login --server US # PowerShell: $env:DR_OAUTH_FLOW="v2"; dr login --server US
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.
179
+
180
+ ```
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
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.99",
11
+ version: "1.6.101",
12
12
  description: "Datarails CLI",
13
13
  type: "module",
14
14
  engines: {
@@ -69,7 +69,7 @@ var package_default = {
69
69
  };
70
70
 
71
71
  // src/buildInfo.ts
72
- var BUILD_TIME = "2026-10-05T12:03:05.855Z";
72
+ var BUILD_TIME = "2026-10-05T14:18:41.284Z";
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
@@ -2657,7 +2664,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
2657
2664
  jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
2658
2665
  },
2659
2666
  // The env var's NAME only — the resolved key never enters any store.
2660
- ...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar }
2667
+ ...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar },
2668
+ ...creds.interactiveLogin === true && { interactiveLogin: true }
2661
2669
  });
2662
2670
  }
2663
2671
  static slotOf(creds, account) {
@@ -2694,7 +2702,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
2694
2702
  // Set-if-present, never deleted on a blob that lost it (the
2695
2703
  // `apiKeyExpiresAt` rule): it is the identity's only credential pointer,
2696
2704
  // and a meta rewrite must not be able to brick the file.
2697
- ...apiKeyEnvVar !== "" && { apiKeyEnvVar }
2705
+ ...apiKeyEnvVar !== "" && { apiKeyEnvVar },
2706
+ ...parsed2.interactiveLogin === true && { interactiveLogin: true }
2698
2707
  };
2699
2708
  }
2700
2709
  async get(_service, account) {
@@ -2788,6 +2797,8 @@ var FileKeyringBackend = class _FileKeyringBackend {
2788
2797
  if (meta.accessApiKeyFingerprint === void 0) delete next.accessApiKeyFingerprint;
2789
2798
  else next.accessApiKeyFingerprint = meta.accessApiKeyFingerprint;
2790
2799
  if (meta.apiKeyEnvVar !== void 0) next.apiKeyEnvVar = meta.apiKeyEnvVar;
2800
+ if (meta.interactiveLogin === true) next.interactiveLogin = true;
2801
+ else delete next.interactiveLogin;
2791
2802
  break;
2792
2803
  }
2793
2804
  }
@@ -4290,9 +4301,17 @@ async function refuseIfProvisionedExternally(action, opts) {
4290
4301
  `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
4302
  );
4292
4303
  }
4293
- if (creds.sessionId !== void 0 && creds.sessionId !== "") return;
4304
+ if (creds.apiKeyEnvVar !== void 0 && opts?.allowApiKey !== true) {
4305
+ throw new Error(
4306
+ `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.`
4307
+ );
4308
+ }
4309
+ if (isInteractiveFileLogin(creds)) return;
4294
4310
  throw provisionedExternallyError(path11, action);
4295
4311
  }
4312
+ function isInteractiveFileLogin(creds) {
4313
+ return creds.sessionId !== void 0 && creds.sessionId !== "" || creds.interactiveLogin === true;
4314
+ }
4296
4315
  async function refuseLoginOverStoredApiKey(store, serverKey, email) {
4297
4316
  let existing;
4298
4317
  try {
@@ -4323,7 +4342,7 @@ async function refuseImpersonationWithCredentialFile() {
4323
4342
  `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
4343
  );
4325
4344
  }
4326
- if (creds.sessionId === void 0 || creds.sessionId === "") {
4345
+ if (!isInteractiveFileLogin(creds)) {
4327
4346
  throw provisionedExternallyError(path11, action);
4328
4347
  }
4329
4348
  throw new Error(
@@ -5007,7 +5026,7 @@ async function runLogout(cfg) {
5007
5026
  const session2 = await cfg.store.getSession(key, meta.email);
5008
5027
  if (session2 === void 0) continue;
5009
5028
  try {
5010
- await revoker(host2, session2);
5029
+ writeNotes(cfg.out, await revoker(host2, session2));
5011
5030
  } catch {
5012
5031
  }
5013
5032
  await cfg.store.removeSession(key, meta.email);
@@ -5040,32 +5059,95 @@ async function runLogout(cfg) {
5040
5059
  }
5041
5060
  const host = resolve(cfg.serverKey).host;
5042
5061
  try {
5043
- await revoker(host, session);
5062
+ writeNotes(cfg.out, await revoker(host, session));
5044
5063
  } catch {
5045
5064
  }
5046
5065
  await cfg.store.removeSession(cfg.serverKey, email);
5047
5066
  cfg.out.write(`Logged out of ${cfg.serverKey} as ${email}.
5048
5067
  `);
5049
5068
  }
5069
+ function writeNotes(out, notes) {
5070
+ for (const line of notes ?? []) out.write(`${line}
5071
+ `);
5072
+ }
5073
+ var REVOKE_TIMEOUT_MS = 5e3;
5050
5074
  async function bestEffortLogout(host, session) {
5051
- if (host === "" || isCookielessSession(session)) return;
5075
+ if (host === "") return [];
5052
5076
  const base = host.replace(/\/+$/, "");
5053
- const controller = new AbortController();
5054
- const timeout = setTimeout(() => controller.abort(), 5e3);
5055
- try {
5056
- await fetch(`${base}/oauth/revoke/`, {
5077
+ if (usesApiKey(session)) {
5078
+ return [
5079
+ "Note: the API key is still valid server-side until it is revoked where it was issued; only the local copy was removed."
5080
+ ];
5081
+ }
5082
+ const refresh = session.jwtRefresh ?? "";
5083
+ const hasCookieSession = !isCookielessSession(session) && session.sessionId !== "" && session.csrfToken !== "";
5084
+ if (refresh === "" && !hasCookieSession) {
5085
+ return [
5086
+ `Warning: no refresh token is stored for this identity, so nothing was revoked on ${base}; only the local copy was removed.`
5087
+ ];
5088
+ }
5089
+ const [refreshFailure, sessionFailure] = await Promise.all([
5090
+ refresh === "" ? void 0 : revokeRefreshToken(base, refresh),
5091
+ hasCookieSession ? endCookieSession(base, session) : void 0
5092
+ ]);
5093
+ const notes = [];
5094
+ if (refreshFailure !== void 0) {
5095
+ notes.push(
5096
+ `Warning: could not revoke the refresh token on ${base} (${refreshFailure}); it stays valid server-side until it expires.`
5097
+ );
5098
+ }
5099
+ if (sessionFailure !== void 0) {
5100
+ notes.push(
5101
+ `Warning: could not end the browser session on ${base} (${sessionFailure}); it stays valid server-side until it expires.`
5102
+ );
5103
+ }
5104
+ return notes;
5105
+ }
5106
+ async function revokeRefreshToken(base, refresh) {
5107
+ return withTimeout(async (signal) => {
5108
+ const res = await fetch(`${base}/jwt/api/token/revoke/`, {
5109
+ method: "POST",
5110
+ headers: { "Content-Type": "application/json" },
5111
+ body: JSON.stringify({ refresh }),
5112
+ signal
5113
+ });
5114
+ await discardBody(res);
5115
+ return res.status === 204 ? void 0 : `HTTP ${res.status}`;
5116
+ });
5117
+ }
5118
+ async function endCookieSession(base, session) {
5119
+ return withTimeout(async (signal) => {
5120
+ const res = await fetch(`${base}/api/accounts/logout/`, {
5057
5121
  method: "POST",
5058
5122
  headers: {
5059
5123
  Cookie: `sessionid=${session.sessionId}; csrftoken=${session.csrfToken}`,
5060
- "X-CSRFToken": session.csrfToken
5124
+ "X-CSRFToken": session.csrfToken,
5125
+ Referer: `${base}/`
5061
5126
  },
5062
- signal: controller.signal
5127
+ redirect: "manual",
5128
+ signal
5063
5129
  });
5130
+ await discardBody(res);
5131
+ return res.status >= 200 && res.status < 400 ? void 0 : `HTTP ${res.status}`;
5132
+ });
5133
+ }
5134
+ async function withTimeout(run) {
5135
+ const controller = new AbortController();
5136
+ const timeout = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS);
5137
+ try {
5138
+ return await run(controller.signal);
5064
5139
  } catch {
5140
+ return controller.signal.aborted ? "timed out" : "network error";
5065
5141
  } finally {
5066
5142
  clearTimeout(timeout);
5067
5143
  }
5068
5144
  }
5145
+ async function discardBody(res) {
5146
+ try {
5147
+ await res.text();
5148
+ } catch {
5149
+ }
5150
+ }
5069
5151
 
5070
5152
  // src/commands/login.ts
5071
5153
  import { Option } from "commander";
@@ -5078,8 +5160,8 @@ var OAUTH_FLOW_ENV = "DR_OAUTH_FLOW";
5078
5160
  function oauthFlowFromEnv(env = process.env) {
5079
5161
  const raw = (env[OAUTH_FLOW_ENV] ?? "").trim();
5080
5162
  const value = raw.toLowerCase();
5081
- if (value === "" || value === "legacy") return "legacy";
5082
- if (value === "v2") return "v2";
5163
+ if (value === "" || value === "v2") return "v2";
5164
+ if (value === "legacy") return "legacy";
5083
5165
  throw new Error(`${OAUTH_FLOW_ENV} must be "legacy" or "v2", got "${raw}"`);
5084
5166
  }
5085
5167
  var PATHS = {
@@ -5089,7 +5171,7 @@ var PATHS = {
5089
5171
  var GRANT_TYPE = "urn:ietf:params:oauth:grant-type:";
5090
5172
  async function requestDeviceCode(host, options) {
5091
5173
  const base = host.replace(/\/+$/, "");
5092
- const flow = options?.flow ?? "legacy";
5174
+ const flow = options?.flow ?? "v2";
5093
5175
  const paths = PATHS[flow];
5094
5176
  const body = new URLSearchParams({ client_id: CLIENT_ID, scope: paths.scope });
5095
5177
  const res = await fetch(`${base}${paths.code}`, {
@@ -5100,7 +5182,12 @@ async function requestDeviceCode(host, options) {
5100
5182
  });
5101
5183
  const text2 = await res.text();
5102
5184
  if (!res.ok) {
5103
- const hint = flow === "v2" && res.status === 429 ? " (too many logins from this address: wait a minute and run `dr login` again)" : "";
5185
+ let hint = "";
5186
+ if (flow === "v2" && res.status === 429) {
5187
+ hint = " (too many logins from this address: wait a minute and run `dr login` again)";
5188
+ } else if (flow === "v2" && res.status === 404) {
5189
+ 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)`;
5190
+ }
5104
5191
  throw new Error(`device/code failed: ${res.status} ${text2.trim()}${hint}`);
5105
5192
  }
5106
5193
  let raw;
@@ -5172,7 +5259,7 @@ function tryParseErrorCode(text2) {
5172
5259
  return typeof errVal === "string" ? errVal : "";
5173
5260
  }
5174
5261
  async function pollForToken(host, deviceCode, options = {}) {
5175
- return poll(host, PATHS.legacy.token, deviceCode, options, (text2) => {
5262
+ return poll(host, PATHS.legacy.token, deviceCode, options, {}, (text2) => {
5176
5263
  const tok = parseTokenResponse(text2);
5177
5264
  if (!tok.sessionId || !tok.csrfToken) {
5178
5265
  throw new Error("token response missing session_id or csrf_token");
@@ -5181,7 +5268,7 @@ async function pollForToken(host, deviceCode, options = {}) {
5181
5268
  });
5182
5269
  }
5183
5270
  async function pollForV2Token(host, deviceCode, options = {}) {
5184
- return poll(host, PATHS.v2.token, deviceCode, options, (text2) => {
5271
+ return poll(host, PATHS.v2.token, deviceCode, options, V2_TERMINAL_HINTS, (text2) => {
5185
5272
  const obj = parseObject(text2);
5186
5273
  const access = typeof obj.access === "string" ? obj.access : "";
5187
5274
  const refresh = typeof obj.refresh === "string" ? obj.refresh : "";
@@ -5191,7 +5278,11 @@ async function pollForV2Token(host, deviceCode, options = {}) {
5191
5278
  return { access, refresh };
5192
5279
  });
5193
5280
  }
5194
- async function poll(host, path11, deviceCode, options, onSuccess) {
5281
+ var V2_TERMINAL_HINTS = {
5282
+ access_denied: " (the login was refused, expired, or already used: run `dr login` again)",
5283
+ token_issuance_failed: " (the server could not issue the token: run `dr login` again to start a new login)"
5284
+ };
5285
+ async function poll(host, path11, deviceCode, options, terminalHints, onSuccess) {
5195
5286
  const interval = options.interval ?? 2e3;
5196
5287
  const maxWait = options.maxWait ?? 6e5;
5197
5288
  const now = options.now ?? Date.now;
@@ -5227,7 +5318,8 @@ async function poll(host, path11, deviceCode, options, onSuccess) {
5227
5318
  continue;
5228
5319
  }
5229
5320
  if (errCode) {
5230
- throw new Error(`token error: ${errCode}`);
5321
+ const hint = Object.hasOwn(terminalHints, errCode) ? terminalHints[errCode] : "";
5322
+ throw new Error(`token error: ${errCode}${hint}`);
5231
5323
  }
5232
5324
  throw new Error(`token HTTP ${res.status}: ${text2.trim()}`);
5233
5325
  }
@@ -5462,11 +5554,6 @@ function registerLoginCommand(root) {
5462
5554
  await refuseIfProvisionedExternally("log in");
5463
5555
  const flow = oauthFlowFromEnv();
5464
5556
  const fileStore = await resolveFileStore(opts.server);
5465
- if (flow === "v2" && fileStore !== void 0) {
5466
- throw new Error(
5467
- `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.`
5468
- );
5469
- }
5470
5557
  let serverKey = fileStore?.key ?? (opts.server ?? "").trim();
5471
5558
  if (fileStore === void 0) {
5472
5559
  if (serverKey === "") {
@@ -5575,13 +5662,14 @@ async function runLogin(cfg) {
5575
5662
  await refuseIfProvisionedExternally("log in");
5576
5663
  const host = cfg.host ?? resolve(cfg.serverKey).host;
5577
5664
  const timeoutMs = cfg.timeoutMs !== void 0 && cfg.timeoutMs > 0 ? cfg.timeoutMs : DEFAULT_LOGIN_TIMEOUT_MS;
5578
- const flow = cfg.flow ?? "legacy";
5665
+ const flow = cfg.flow;
5579
5666
  const controller = new AbortController();
5580
5667
  const overallTimer = setTimeout(() => controller.abort(), timeoutMs + ABORT_SLACK_MS);
5581
5668
  try {
5582
- if (flow === "v2") {
5583
- cfg.out.write(`Using the v2 device flow (DR_OAUTH_FLOW=v2).
5584
- `);
5669
+ if (flow === "legacy") {
5670
+ cfg.out.write(
5671
+ "Using the legacy device flow (DR_OAUTH_FLOW=legacy): its token carries no credential scope.\n"
5672
+ );
5585
5673
  }
5586
5674
  const dc = await requestDeviceCode(host, { signal: controller.signal, flow });
5587
5675
  cfg.out.write(`Opening browser to authenticate with Datarails (${cfg.serverKey})...
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.99",
3
+ "cliVersion": "1.6.101",
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": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-cli",
3
- "version": "1.6.99",
3
+ "version": "1.6.101",
4
4
  "description": "Datarails CLI",
5
5
  "type": "module",
6
6
  "engines": {