@alter-ai/cli 0.1.0 → 0.3.0

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +247 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ The fastest interactive sign-in is the browser-dance flow — `alter auth login`
16
16
 
17
17
  ```bash
18
18
  alter auth login
19
- # alter: opening browser at https://dashboard.alterauth.com/cli-auth
19
+ # alter: opening browser at https://portal.alterauth.com/cli-auth
20
20
  # (waiting up to 2 minutes for you to approve)…
21
21
  # alter: signed in via browser-dance flow.
22
22
 
package/dist/cli.js CHANGED
@@ -176,7 +176,7 @@ async function maybePrintUpdateBanner(currentVersion, argv2 = process.argv.slice
176
176
  // package.json
177
177
  var package_default = {
178
178
  name: "@alter-ai/cli",
179
- version: "0.1.0",
179
+ version: "0.3.0",
180
180
  description: "Command-line interface for the Alter Vault dev portal \u2014 scripted dashboard automation.",
181
181
  type: "module",
182
182
  bin: {
@@ -440,10 +440,23 @@ var GrantRevokedError = class extends ReAuthRequiredError {
440
440
  };
441
441
  var CredentialRevokedError = class extends ReAuthRequiredError {
442
442
  grantId;
443
- constructor(message, grantId, details) {
443
+ // Recovery-context fields. Populated by the backend at the raise
444
+ // site in token_service so callers can mint a Connect session for
445
+ // the same (user, provider) without re-fetching the grant. See
446
+ // docs/planning/CONNECT_RECOVERY_CONTEXT.md.
447
+ providerId;
448
+ appUserId;
449
+ // Recovery context is appended AFTER `details` so the existing
450
+ // positional contract `new CredentialRevokedError("msg", "g-1",
451
+ // detailsObj)` keeps compiling AND keeps assigning detailsObj to
452
+ // the `details` slot — only the wire-parsing site uses the new
453
+ // positional slots.
454
+ constructor(message, grantId, details, providerId, appUserId) {
444
455
  super(message, details);
445
456
  this.name = "CredentialRevokedError";
446
457
  this.grantId = grantId;
458
+ this.providerId = providerId;
459
+ this.appUserId = appUserId;
447
460
  }
448
461
  };
449
462
  var GrantDeletedError = class extends ReAuthRequiredError {
@@ -453,9 +466,19 @@ var GrantDeletedError = class extends ReAuthRequiredError {
453
466
  }
454
467
  };
455
468
  var GrantNotFoundError = class extends BackendError {
456
- constructor(message, details) {
469
+ providerId;
470
+ agentId;
471
+ appUserId;
472
+ // Recovery context is appended AFTER `details` so the existing
473
+ // positional contract `new GrantNotFoundError("msg", detailsObj)`
474
+ // keeps compiling AND keeps assigning detailsObj to the `details`
475
+ // slot — only the wire-parsing site uses the new positional slots.
476
+ constructor(message, details, providerId, agentId, appUserId) {
457
477
  super(message, details);
458
478
  this.name = "GrantNotFoundError";
479
+ this.providerId = providerId;
480
+ this.agentId = agentId;
481
+ this.appUserId = appUserId;
459
482
  }
460
483
  };
461
484
  var AmbiguousGrantError = class extends BackendError {
@@ -468,23 +491,43 @@ var AmbiguousGrantError = class extends BackendError {
468
491
  // UUIDs only (no emails / names) — same backend hygiene rule that
469
492
  // applies to the rest of the cross-tenant probe surface.
470
493
  appUserIds;
471
- constructor(message, providerId, accountIdentifiers, accountWasProvided, appUserIds, details) {
494
+ // Populated for the managed-secret grant-level flavor: one user
495
+ // delegated multiple managed-secret grants sharing the same
496
+ // template to the same agent. The SDK caller picks one via
497
+ // `grantId=` on the next request. UUIDs only.
498
+ grantIds;
499
+ constructor(message, providerId, accountIdentifiers, accountWasProvided, appUserIds, details, grantIds) {
472
500
  super(message, details);
473
501
  this.name = "AmbiguousGrantError";
474
502
  this.providerId = providerId;
475
503
  this.accountIdentifiers = accountIdentifiers ?? [];
476
504
  this.accountWasProvided = accountWasProvided ?? false;
477
505
  this.appUserIds = appUserIds ?? [];
506
+ this.grantIds = grantIds ?? [];
478
507
  }
479
508
  };
480
509
  var NoDelegatedGrantError = class extends BackendError {
510
+ // `providerId` + `agentId` shipped in the pre-recovery PR, so they
511
+ // KEEP their positional slots. `appUserId` is new in this PR and
512
+ // appended AFTER `details` so the existing 4-arg call shape
513
+ // `new NoDelegatedGrantError(msg, providerId, agentId, details)`
514
+ // continues to work — only the wire-parsing site uses the new
515
+ // appUserId slot.
481
516
  providerId;
482
517
  agentId;
483
- constructor(message, providerId, agentId, details) {
518
+ // Populated when the resolution applied a user filter. Diagnostic /
519
+ // correlation field only — the recovery helper does NOT auto-bind
520
+ // the session to the user. Pass `userToken=` to
521
+ // `createConnectSessionForError` (or configure `userTokenGetter` on
522
+ // the client) when user binding is required. See
523
+ // docs/planning/CONNECT_RECOVERY_CONTEXT.md.
524
+ appUserId;
525
+ constructor(message, providerId, agentId, details, appUserId) {
484
526
  super(message, details);
485
527
  this.name = "NoDelegatedGrantError";
486
528
  this.providerId = providerId;
487
529
  this.agentId = agentId;
530
+ this.appUserId = appUserId;
488
531
  }
489
532
  };
490
533
  var PolicyViolationError = class extends BackendError {
@@ -2895,7 +2938,7 @@ function _extractAdditionalCredentials(token) {
2895
2938
  return _additionalCredsStore.get(token);
2896
2939
  }
2897
2940
  var _fetch;
2898
- var SDK_VERSION = "0.14.0";
2941
+ var SDK_VERSION = "0.15.0";
2899
2942
  var SDK_USER_AGENT = `alter-sdk-node/${SDK_VERSION}`;
2900
2943
  var HTTP_FORBIDDEN = 403;
2901
2944
  var HTTP_NO_CONTENT2 = 204;
@@ -3506,7 +3549,10 @@ ${effectiveConstraints}`;
3506
3549
  if (errorCode === "credential_revoked") {
3507
3550
  throw new CredentialRevokedError(
3508
3551
  errorData.message ?? "Credential has been revoked. User must re-authorize.",
3509
- errorData.grant_id
3552
+ errorData.grant_id,
3553
+ errorData,
3554
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3555
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3510
3556
  );
3511
3557
  }
3512
3558
  if (errorData.error === "scope_mismatch") {
@@ -3546,13 +3592,17 @@ ${effectiveConstraints}`;
3546
3592
  const appUserIds = Array.isArray(errorData.app_user_ids) ? errorData.app_user_ids.filter(
3547
3593
  (v) => typeof v === "string"
3548
3594
  ) : [];
3595
+ const grantIds = Array.isArray(errorData.grant_ids) ? errorData.grant_ids.filter(
3596
+ (v) => typeof v === "string"
3597
+ ) : [];
3549
3598
  throw new AmbiguousGrantError(
3550
3599
  errorData.message ?? "JWT identity resolved to multiple grants. Pass the 'account' parameter to disambiguate.",
3551
3600
  typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3552
3601
  accountIdentifiers,
3553
3602
  errorData.account_was_provided === true,
3554
3603
  appUserIds,
3555
- errorData
3604
+ errorData,
3605
+ grantIds
3556
3606
  );
3557
3607
  }
3558
3608
  if (errorData.error === "no_delegated_grant") {
@@ -3560,7 +3610,8 @@ ${effectiveConstraints}`;
3560
3610
  errorData.message ?? "Agent has no active delegation or managed-secret grant for this provider.",
3561
3611
  typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3562
3612
  typeof errorData.agent_id === "string" ? errorData.agent_id : void 0,
3563
- errorData
3613
+ errorData,
3614
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3564
3615
  );
3565
3616
  }
3566
3617
  throw new BackendError(
@@ -3577,9 +3628,21 @@ ${effectiveConstraints}`;
3577
3628
  }
3578
3629
  if (response.status === HTTP_NOT_FOUND) {
3579
3630
  const errorData = await __VaultClient.#safeParseJson(response);
3631
+ if (errorData.error === "no_delegated_grant") {
3632
+ throw new NoDelegatedGrantError(
3633
+ errorData.message ?? "Agent has no active delegation or managed-secret grant for this provider.",
3634
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3635
+ typeof errorData.agent_id === "string" ? errorData.agent_id : void 0,
3636
+ errorData,
3637
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3638
+ );
3639
+ }
3580
3640
  throw new GrantNotFoundError(
3581
3641
  errorData.message ?? "OAuth grant not found for the given grant_id",
3582
- errorData
3642
+ errorData,
3643
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3644
+ typeof errorData.agent_id === "string" ? errorData.agent_id : void 0,
3645
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3583
3646
  );
3584
3647
  }
3585
3648
  if (response.status === HTTP_BAD_REQUEST || response.status === HTTP_BAD_GATEWAY) {
@@ -3595,7 +3658,9 @@ ${effectiveConstraints}`;
3595
3658
  throw new CredentialRevokedError(
3596
3659
  errorData.message ?? "Underlying credential has been revoked. User must re-authorize.",
3597
3660
  errorData.grant_id,
3598
- errorData
3661
+ errorData,
3662
+ typeof errorData.provider_id === "string" ? errorData.provider_id : void 0,
3663
+ typeof errorData.app_user_id === "string" ? errorData.app_user_id : void 0
3599
3664
  );
3600
3665
  }
3601
3666
  throw new BackendError(
@@ -4787,7 +4852,14 @@ ${effectiveConstraints}`;
4787
4852
  }
4788
4853
  sessionBody.agent = options.agent;
4789
4854
  }
4790
- if (this.#userTokenGetter) {
4855
+ if (options.userToken !== void 0) {
4856
+ if (typeof options.userToken !== "string" || options.userToken.length === 0) {
4857
+ throw new AlterValueError(
4858
+ "userToken must be a non-empty string when provided"
4859
+ );
4860
+ }
4861
+ sessionBody.user_token = options.userToken;
4862
+ } else if (this.#userTokenGetter) {
4791
4863
  sessionBody.user_token = await this.#getUserToken();
4792
4864
  }
4793
4865
  const sessionPath = "/sdk/oauth/connect/session";
@@ -4951,10 +5023,43 @@ ${effectiveConstraints}`;
4951
5023
  } else {
4952
5024
  console.log(`Open this URL to authorize: ${session.connectUrl}`);
4953
5025
  }
5026
+ return await this.pollConnectSession(session.sessionToken, {
5027
+ timeoutMs,
5028
+ pollIntervalMs
5029
+ });
5030
+ }
5031
+ /**
5032
+ * Poll a Connect session to completion.
5033
+ *
5034
+ * Use this when your code minted the Connect session itself (e.g.,
5035
+ * via {@link createConnectSession} for a custom UI flow, or via
5036
+ * {@link createConnectSessionForError} for a recovery flow) and
5037
+ * you need to block until the user finishes the consent screen.
5038
+ * {@link connect} is the all-in-one convenience that mints + opens
5039
+ * the browser + polls; this method is the polling half on its own.
5040
+ *
5041
+ * @param sessionToken - A session token from
5042
+ * {@link createConnectSession} or
5043
+ * {@link createConnectSessionForError}.
5044
+ * @param options - `timeoutMs` (default 300000 = 5 min) and
5045
+ * `pollIntervalMs` (default 2000). Milliseconds per ecosystem
5046
+ * convention; the Python SDK's equivalent uses seconds.
5047
+ * @returns One `ConnectResult` per provider the user completed
5048
+ * within the session (multi-provider Connect sessions yield
5049
+ * multiple results).
5050
+ * @throws ConnectTimeoutError if the session didn't complete within
5051
+ * `timeoutMs`.
5052
+ * @throws ConnectFlowError / ConnectDeniedError / ConnectConfigError
5053
+ * for user denial, session expiry, or unrecognized status.
5054
+ * @throws AlterSDKError if the SDK instance has been closed.
5055
+ */
5056
+ async pollConnectSession(sessionToken, options) {
5057
+ this.#assertNotClosed();
5058
+ const timeoutMs = options?.timeoutMs ?? 3e5;
5059
+ const pollIntervalMs = options?.pollIntervalMs ?? 2e3;
4954
5060
  const deadline = Date.now() + timeoutMs;
4955
- while (Date.now() < deadline) {
4956
- await new Promise((resolve2) => setTimeout(resolve2, pollIntervalMs));
4957
- const pollResult = await this.#pollSession(session.sessionToken);
5061
+ while (true) {
5062
+ const pollResult = await this.#pollSession(sessionToken);
4958
5063
  const pollStatus = pollResult.status;
4959
5064
  if (pollStatus === "completed") {
4960
5065
  const grantsData = pollResult.grants ?? [];
@@ -4997,12 +5102,95 @@ ${effectiveConstraints}`;
4997
5102
  { status: pollStatus }
4998
5103
  );
4999
5104
  }
5105
+ const remaining = deadline - Date.now();
5106
+ if (remaining <= 0) break;
5107
+ const sleepMs = Math.min(pollIntervalMs, remaining);
5108
+ await new Promise((resolve2) => setTimeout(resolve2, sleepMs));
5000
5109
  }
5001
5110
  throw new ConnectTimeoutError(
5002
5111
  `OAuth flow did not complete within ${Math.round(timeoutMs / 1e3)} seconds. The user may not have finished authorizing in the browser.`,
5003
5112
  { timeoutMs }
5004
5113
  );
5005
5114
  }
5115
+ /**
5116
+ * Mint a recovery Connect session from a typed error.
5117
+ *
5118
+ * Use this in the catch block of an identity-mode or agent-mode
5119
+ * request that failed because the user hasn't authorized the
5120
+ * provider yet ({@link NoDelegatedGrantError}), or because the
5121
+ * underlying credential is permanently broken
5122
+ * ({@link CredentialRevokedError}), or because the resolved grant
5123
+ * doesn't exist ({@link GrantNotFoundError} with identity-mode
5124
+ * context).
5125
+ *
5126
+ * What the method reuses from the error:
5127
+ * - `providerId` → threaded into `allowedProviders=[...]`. Required;
5128
+ * missing context throws `AlterValueError`.
5129
+ * - `agentId` → threaded into `agent=` so the recovery session
5130
+ * re-binds the same delegation target. Required for
5131
+ * `NoDelegatedGrantError` recovery; absent on
5132
+ * `GrantNotFoundError` / `CredentialRevokedError` for user-direct
5133
+ * grants, in which case the call falls through to non-delegated
5134
+ * recovery.
5135
+ *
5136
+ * What the method does NOT reuse from the error:
5137
+ * - `appUserId` is exposed on the typed error for caller use
5138
+ * (logging, audit correlation, deciding which user to re-prompt)
5139
+ * but is NOT threaded into the recovery session.
5140
+ * `createConnectSession` binds the session via `userToken` (JWT),
5141
+ * not `appUserId`. To bind the session, pass `userToken=`
5142
+ * explicitly or configure `userTokenGetter` on the SDK client.
5143
+ *
5144
+ * @example
5145
+ * ```ts
5146
+ * try {
5147
+ * await vault.request(HttpMethod.GET, "https://...", { provider: "<provider-id>" });
5148
+ * } catch (e) {
5149
+ * if (e instanceof NoDelegatedGrantError) {
5150
+ * const session = await vault.createConnectSessionForError(e, {
5151
+ * allowedOrigin: "https://app.example.com",
5152
+ * });
5153
+ * redirectUser(session.connectUrl);
5154
+ * const results = await vault.pollConnectSession(session.sessionToken);
5155
+ * // Retry with results[0].grantId
5156
+ * }
5157
+ * }
5158
+ * ```
5159
+ *
5160
+ * @param error - The typed exception. Must carry `providerId`; if
5161
+ * `undefined` (direct-mode 404 from a stale grant_id), recovery
5162
+ * isn't derivable and this method throws `AlterValueError`
5163
+ * rather than guessing.
5164
+ * @param options - Standard `createConnectSession` options. Supplied
5165
+ * per call so the recovery session matches the deployment
5166
+ * shape (popup, mobile redirect, headless). The convenience
5167
+ * method doesn't infer these from the error.
5168
+ * @throws AlterValueError if `error.providerId` is `undefined`.
5169
+ */
5170
+ async createConnectSessionForError(error, options) {
5171
+ const providerId = error.providerId;
5172
+ if (providerId === void 0) {
5173
+ throw new AlterValueError(
5174
+ "Cannot mint a recovery Connect session: the typed error has no providerId context. This usually means the original call used direct grantId mode and the grantId was stale \u2014 there's no (user, provider) tuple to recover. Catch GrantNotFoundError separately and call createConnectSession() directly with the providers the user should re-authorize."
5175
+ );
5176
+ }
5177
+ const agentId = error.agentId;
5178
+ if (error instanceof NoDelegatedGrantError && agentId === void 0) {
5179
+ throw new AlterValueError(
5180
+ "Cannot mint a delegated recovery Connect session: the typed NoDelegatedGrantError has no agentId context. The backend may be on a version predating the recovery-context fields, or the wire payload was malformed. Upgrade the backend, or catch this and call createConnectSession() explicitly with the right agent=."
5181
+ );
5182
+ }
5183
+ return await this.createConnectSession({
5184
+ allowedProviders: [providerId],
5185
+ allowedOrigin: options?.allowedOrigin,
5186
+ returnUrl: options?.returnUrl,
5187
+ metadata: options?.metadata,
5188
+ grantPolicy: options?.grantPolicy,
5189
+ requiredScopes: options?.requiredScopes,
5190
+ agent: agentId,
5191
+ userToken: options?.userToken
5192
+ });
5193
+ }
5006
5194
  /**
5007
5195
  * Trigger IDP login for end user via browser.
5008
5196
  *
@@ -6539,8 +6727,13 @@ var _keytarCache;
6539
6727
  async function loadKeytar() {
6540
6728
  if (_keytarCache !== void 0) return _keytarCache;
6541
6729
  try {
6542
- const mod = await import("keytar");
6543
- _keytarCache = mod;
6730
+ const raw = await import("keytar");
6731
+ const candidate = raw.default ?? raw;
6732
+ if (typeof candidate.getPassword === "function" && typeof candidate.setPassword === "function" && typeof candidate.deletePassword === "function") {
6733
+ _keytarCache = candidate;
6734
+ } else {
6735
+ _keytarCache = null;
6736
+ }
6544
6737
  } catch {
6545
6738
  _keytarCache = null;
6546
6739
  }
@@ -6548,7 +6741,7 @@ async function loadKeytar() {
6548
6741
  }
6549
6742
  function printPlaintextFallbackWarning() {
6550
6743
  process.stderr.write(
6551
- "alter: WARNING \u2014 saving the PAT to a plaintext file (~/.config/alter/auth.toml).\n The OS keychain (keytar) is not available on this host. Install the\n native build tools and re-run `npm install -g @alter-ai/cli` to enable\n secure storage:\n macOS: xcode-select --install\n Linux: sudo apt install libsecret-1-dev gnome-keyring (or distro equivalent)\n Windows: install windows-build-tools or VS Build Tools\n"
6744
+ "alter: WARNING \u2014 saving the PAT to a plaintext file (~/.config/alter/auth.toml, mode 0600).\n The OS keychain (keytar) is not usable on this host. Common causes:\n - keytar's native module failed to install (Linux: install libsecret-1-dev +\n gnome-keyring; Windows: install VS Build Tools; macOS: usually pre-installed).\n - The native module is present but its API surface doesn't match what the CLI\n expects (file a bug against @alter-ai/cli with your node + npm versions).\n If the cleartext fallback is unacceptable on this host, revoke the PAT from the\n dashboard's `Personal Access Tokens` page after use.\n"
6552
6745
  );
6553
6746
  }
6554
6747
  async function loadStoredAuth2() {
@@ -6642,11 +6835,11 @@ async function clearStoredAuth2() {
6642
6835
 
6643
6836
  // src/portal-client.ts
6644
6837
  import { platform, release } from "os";
6645
- var DEFAULT_BASE_URL = "https://api.alterauth.com";
6838
+ var DEFAULT_BASE_URL = "https://backend.alterauth.com";
6646
6839
  var PAT_API_PREFIX = "/api/v1/dev-portal";
6647
6840
  var HTTP_ERROR_THRESHOLD = 400;
6648
6841
  var DEFAULT_TIMEOUT_MS = 3e4;
6649
- var CLI_VERSION = "0.1.0";
6842
+ var CLI_VERSION = "0.3.0";
6650
6843
  var USER_AGENT = buildUserAgent();
6651
6844
  function buildUserAgent() {
6652
6845
  let osTag = "";
@@ -9383,7 +9576,7 @@ var DEFAULT_SCOPES = [
9383
9576
  "dashboard_secrets:write"
9384
9577
  ];
9385
9578
  function deriveDashboardUrl(baseUrl) {
9386
- return baseUrl.replace(/^https:\/\/api\./, "https://dashboard.");
9579
+ return baseUrl.replace(/^https:\/\/backend\./, "https://portal.");
9387
9580
  }
9388
9581
  function defaultOpenBrowser(url) {
9389
9582
  let command;
@@ -9500,7 +9693,7 @@ async function runBrowserDance(options) {
9500
9693
  server.close();
9501
9694
  reject(
9502
9695
  new Error(
9503
- "browser-dance callback came from an unexpected origin \u2014 refusing to accept. Re-run ``alter auth login`` and verify the dashboard URL."
9696
+ `browser-dance callback came from an unexpected origin \u2014 refusing to accept. Expected origin ${JSON.stringify(dashboardOrigin)}; received Origin: ${JSON.stringify(origin || "(none)")}, Referer: ${JSON.stringify(referer || "(none)")}. If your dashboard isn't at the regex-derived host, re-run with --dashboard-url <https-url> pointing at the actual dashboard origin.`
9504
9697
  )
9505
9698
  );
9506
9699
  return;
@@ -9634,8 +9827,21 @@ async function runBrowserDance(options) {
9634
9827
  pat,
9635
9828
  ...options.baseUrl ? { baseUrl: options.baseUrl } : {}
9636
9829
  });
9830
+ const smokeTest = options.smokeTest ?? defaultSmokeTest;
9831
+ await smokeTest(pat, options.baseUrl);
9637
9832
  process.stdout.write("alter: signed in via browser-dance flow.\n");
9638
9833
  }
9834
+ async function defaultSmokeTest(pat, baseUrl) {
9835
+ const client = new DashboardClient({
9836
+ pat,
9837
+ ...baseUrl ? { baseUrl } : {}
9838
+ });
9839
+ try {
9840
+ await client.pats.whoami();
9841
+ } finally {
9842
+ await client.close();
9843
+ }
9844
+ }
9639
9845
 
9640
9846
  // src/scope-catalog.ts
9641
9847
  var DASHBOARD_RESOURCE_VERBS = {
@@ -9849,7 +10055,7 @@ async function loginCommand(options) {
9849
10055
  let resolvedBaseUrl2;
9850
10056
  try {
9851
10057
  const envBaseUrl = process.env.ALTER_BASE_URL?.trim() || null;
9852
- resolvedBaseUrl2 = options.baseUrl ? validateBaseUrl(options.baseUrl) : envBaseUrl ? validateBaseUrl(envBaseUrl) : "https://api.alterauth.com";
10058
+ resolvedBaseUrl2 = options.baseUrl ? validateBaseUrl(options.baseUrl) : envBaseUrl ? validateBaseUrl(envBaseUrl) : "https://backend.alterauth.com";
9853
10059
  } catch (e) {
9854
10060
  err(e instanceof Error ? e.message : String(e));
9855
10061
  process.exit(EXIT_USAGE);
@@ -9875,10 +10081,8 @@ async function loginCommand(options) {
9875
10081
  process.exit(EXIT_USAGE);
9876
10082
  }
9877
10083
  }
9878
- let bindOverride = null;
9879
- if (options.bindIp === false) {
9880
- bindOverride = { bind: "none" };
9881
- } else if (typeof options.bindIp === "string") {
10084
+ let bindOverride = { bind: "none" };
10085
+ if (typeof options.bindIp === "string") {
9882
10086
  let normalisedCidr;
9883
10087
  try {
9884
10088
  normalisedCidr = validateBindIpFlag(options.bindIp);
@@ -9897,6 +10101,19 @@ async function loginCommand(options) {
9897
10101
  });
9898
10102
  return;
9899
10103
  } catch (e) {
10104
+ if (e instanceof PortalBackendError) {
10105
+ const isAuthTier = e.statusCode === 401 || e.statusCode === 403;
10106
+ if (isAuthTier) {
10107
+ err(`PAT was minted but cannot authenticate: ${e.message}`);
10108
+ } else {
10109
+ err(`PAT was minted but the backend rejected the smoke test: ${e.message}`);
10110
+ }
10111
+ process.exit(exitCodeForHttpStatus(e.statusCode));
10112
+ }
10113
+ if (e instanceof NetworkError) {
10114
+ err(`PAT was minted but the backend is unreachable: ${e.message}. Check connectivity and retry.`);
10115
+ process.exit(EXIT_ERROR);
10116
+ }
9900
10117
  err(e instanceof Error ? e.message : String(e));
9901
10118
  process.exit(EXIT_ERROR);
9902
10119
  }
@@ -10061,16 +10278,16 @@ function buildAuthCommand() {
10061
10278
  "Override the backend URL (must be https://). Defaults to ALTER_BASE_URL or production."
10062
10279
  ).option(
10063
10280
  "--dashboard-url <url>",
10064
- "Override the dashboard URL used by the browser-dance flow. Defaults to the backend URL with the leftmost `api.` subdomain swapped for `dashboard.`. Use this when the dashboard isn't at the regex-derivable host (e.g. staging / self-hosted setups). Must use https://."
10281
+ "Override the dashboard URL used by the browser-dance flow. Defaults to the backend URL with the leftmost `backend.` subdomain swapped for `portal.`. Use this when the dashboard isn't at the regex-derivable host (e.g. staging / self-hosted setups). Must use https://."
10065
10282
  ).option(
10066
10283
  "--scopes <list>",
10067
10284
  "Comma-separated scope list to request on the browser-dance consent page (e.g. 'dashboard_apps:read,dashboard_keys:write'). Defaults to the full read+write set across the CLI surface \u2014 enough for every non-destructive day-to-day command. Opt in to admin / delete tiers explicitly: 'dashboard_keys:admin' for runtime-key admin ops, 'dashboard_apps:delete' for cascade-delete an app (note: dashboard_apps does NOT support :admin \u2014 the Destructive-Action Policy separates :delete as its own verb). Each entry is validated client-side against the dashboard scope catalog \u2014 typos fail fast. Mutually exclusive with --token / --token-file / --token-stdin \u2014 pre-minted PATs have their scope set baked at mint time on the dashboard's Settings page; combining the flags is a usage error."
10068
10285
  ).option(
10069
10286
  "--no-bind-ip",
10070
- "Mint the PAT WITHOUT an IP allowlist. The resulting token works from any source IP \u2014 useful when the dashboard sits behind a CDN whose edge IPs aren't yet trusted by the backend (in that case the default 'bind to caller IP' resolves to the CDN edge instead of the operator's real IP, leaving the PAT unusable from the CLI). Browser-dance flow only."
10287
+ "Explicit opt-out of IP binding. This is now the DEFAULT for the browser-dance flow, so the flag is a no-op alias retained for scripts that pass it explicitly. The resulting PAT works from any source IP. Browser-dance flow only."
10071
10288
  ).option(
10072
10289
  "--bind-ip <cidr>",
10073
- "Mint the PAT bound to the operator-supplied IP / CIDR (e.g. '203.0.113.5' for a single host, '203.0.113.0/24' for a range, or an IPv6 equivalent). The backend's automatic caller-IP bind is bypassed; the supplied range becomes the entire allowlist. Use this when the operator's egress IP is known but the dashboard sits behind a CDN whose trusted-proxy list isn't yet populated on the backend. If both --bind-ip and --no-bind-ip appear, the one that appears LAST on the command line takes effect (Commander last-wins). Browser-dance flow only."
10290
+ "Mint the PAT bound to the operator-supplied IP / CIDR (e.g. '203.0.113.5' for a single host, '203.0.113.0/24' for a range, or an IPv6 equivalent). The supplied range becomes the entire allowlist on the minted PAT. Use this when the egress IP is known and stable \u2014 e.g. minting a token for a CI runner whose outbound CIDR you control. Interactive developer-laptop logins should leave this unset (default is now unbound; see --no-bind-ip). If both --bind-ip and --no-bind-ip appear, the one that appears LAST on the command line takes effect (Commander last-wins). Browser-dance flow only."
10074
10291
  ).action(async (options) => {
10075
10292
  await loginCommand(options);
10076
10293
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alter-ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line interface for the Alter Vault dev portal — scripted dashboard automation.",
5
5
  "type": "module",
6
6
  "bin": {