@datarails-ai/dr-cli 1.6.100 → 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.100",
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:55:55.787Z";
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(
@@ -5141,8 +5160,8 @@ var OAUTH_FLOW_ENV = "DR_OAUTH_FLOW";
5141
5160
  function oauthFlowFromEnv(env = process.env) {
5142
5161
  const raw = (env[OAUTH_FLOW_ENV] ?? "").trim();
5143
5162
  const value = raw.toLowerCase();
5144
- if (value === "" || value === "legacy") return "legacy";
5145
- if (value === "v2") return "v2";
5163
+ if (value === "" || value === "v2") return "v2";
5164
+ if (value === "legacy") return "legacy";
5146
5165
  throw new Error(`${OAUTH_FLOW_ENV} must be "legacy" or "v2", got "${raw}"`);
5147
5166
  }
5148
5167
  var PATHS = {
@@ -5152,7 +5171,7 @@ var PATHS = {
5152
5171
  var GRANT_TYPE = "urn:ietf:params:oauth:grant-type:";
5153
5172
  async function requestDeviceCode(host, options) {
5154
5173
  const base = host.replace(/\/+$/, "");
5155
- const flow = options?.flow ?? "legacy";
5174
+ const flow = options?.flow ?? "v2";
5156
5175
  const paths = PATHS[flow];
5157
5176
  const body = new URLSearchParams({ client_id: CLIENT_ID, scope: paths.scope });
5158
5177
  const res = await fetch(`${base}${paths.code}`, {
@@ -5163,7 +5182,12 @@ async function requestDeviceCode(host, options) {
5163
5182
  });
5164
5183
  const text2 = await res.text();
5165
5184
  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)" : "";
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
+ }
5167
5191
  throw new Error(`device/code failed: ${res.status} ${text2.trim()}${hint}`);
5168
5192
  }
5169
5193
  let raw;
@@ -5530,11 +5554,6 @@ function registerLoginCommand(root) {
5530
5554
  await refuseIfProvisionedExternally("log in");
5531
5555
  const flow = oauthFlowFromEnv();
5532
5556
  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
5557
  let serverKey = fileStore?.key ?? (opts.server ?? "").trim();
5539
5558
  if (fileStore === void 0) {
5540
5559
  if (serverKey === "") {
@@ -5643,13 +5662,14 @@ async function runLogin(cfg) {
5643
5662
  await refuseIfProvisionedExternally("log in");
5644
5663
  const host = cfg.host ?? resolve(cfg.serverKey).host;
5645
5664
  const timeoutMs = cfg.timeoutMs !== void 0 && cfg.timeoutMs > 0 ? cfg.timeoutMs : DEFAULT_LOGIN_TIMEOUT_MS;
5646
- const flow = cfg.flow ?? "legacy";
5665
+ const flow = cfg.flow;
5647
5666
  const controller = new AbortController();
5648
5667
  const overallTimer = setTimeout(() => controller.abort(), timeoutMs + ABORT_SLACK_MS);
5649
5668
  try {
5650
- if (flow === "v2") {
5651
- cfg.out.write(`Using the v2 device flow (DR_OAUTH_FLOW=v2).
5652
- `);
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
+ );
5653
5673
  }
5654
5674
  const dc = await requestDeviceCode(host, { signal: controller.signal, flow });
5655
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.100",
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.100",
3
+ "version": "1.6.101",
4
4
  "description": "Datarails CLI",
5
5
  "type": "module",
6
6
  "engines": {