@notis_ai/cli 0.2.9 → 0.2.11

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.
@@ -18,6 +18,7 @@ import { spawn } from 'node:child_process';
18
18
  import { createInterface } from 'node:readline/promises';
19
19
 
20
20
  import { CliError, EXIT_CODES } from './errors.js';
21
+ import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
21
22
  import {
22
23
  credentialIsExpired,
23
24
  ensureProfile,
@@ -43,13 +44,13 @@ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
43
44
  const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
44
45
  const OAUTH_HTTP_TIMEOUT_MS = 10_000;
45
46
 
46
- function oauthError(code, message, details = {}) {
47
+ function oauthError(code, message, hints = null, details = {}) {
47
48
  return new CliError({
48
49
  code,
49
50
  message,
50
51
  exitCode: EXIT_CODES.auth,
51
52
  details,
52
- hints: [
53
+ hints: hints || [
53
54
  { command: 'notis login', reason: 'Start a new browser authorization' },
54
55
  { command: 'notis doctor', reason: 'Inspect the active credential state' },
55
56
  ],
@@ -82,6 +83,7 @@ async function fetchJson(url, init = {}, fetchImpl = fetch) {
82
83
  throw oauthError(
83
84
  payload.error || 'oauth_request_failed',
84
85
  payload.error_description || payload.message || `OAuth request failed with status ${response.status}`,
86
+ null,
85
87
  payload,
86
88
  );
87
89
  }
@@ -668,12 +670,26 @@ async function exchangeCode(metadata, {
668
670
  function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
669
671
  const now = Math.floor(Date.now() / 1000);
670
672
  const payload = decodeJwtPayload(tokenResponse.access_token);
673
+ const oauthApiBase = (metadata.apiBase || runtime.apiBase || '').replace(/\/+$/, '');
674
+ let beta;
675
+ try {
676
+ const hostname = new URL(oauthApiBase).hostname;
677
+ if (hostname === 'api-beta.notis.ai') beta = true;
678
+ else if (hostname === 'api.notis.ai') beta = false;
679
+ } catch {
680
+ beta = undefined;
681
+ }
671
682
  const config = updateConfig((latest) => {
672
683
  const next = ensureProfile(latest, runtime.profileName);
673
684
  const profile = next.profiles[runtime.profileName];
674
685
  next.profiles[runtime.profileName] = {
675
686
  ...profile,
676
- oauth_api_base: metadata.apiBase || runtime.apiBase,
687
+ // The grant defines this profile's endpoint. A profile is one account on
688
+ // one API, and the environment the user just authorized against is the
689
+ // only endpoint the resulting token is accepted by.
690
+ api_base: oauthApiBase || profile.api_base,
691
+ beta: beta ?? profile.beta,
692
+ oauth_api_base: oauthApiBase || profile.oauth_api_base,
677
693
  oauth_resource: metadata.resource,
678
694
  oauth_access_token: tokenResponse.access_token,
679
695
  oauth_refresh_token: tokenResponse.refresh_token || profile.oauth_refresh_token,
@@ -686,7 +702,7 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
686
702
  oauth_user_id: payload.sub || payload.notis_user_id,
687
703
  };
688
704
  return next;
689
- }, runtime.worktreeRuntime);
705
+ });
690
706
  return config.profiles[runtime.profileName];
691
707
  }
692
708
 
@@ -695,11 +711,11 @@ function pendingAuthorizationFile(runtime) {
695
711
  .update(String(runtime.profileName || 'default'))
696
712
  .digest('hex')
697
713
  .slice(0, 16);
698
- return `${resolveConfigFile(runtime)}.pending-login.${profileKey}`;
714
+ return `${resolveConfigFile()}.pending-login.${profileKey}`;
699
715
  }
700
716
 
701
717
  function legacyPendingAuthorizationFile(runtime) {
702
- return `${resolveConfigFile(runtime)}.pending-login`;
718
+ return `${resolveConfigFile()}.pending-login`;
703
719
  }
704
720
 
705
721
  // The PKCE verifier outlives the process that created it whenever the browser
@@ -739,10 +755,6 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
739
755
  }
740
756
  }
741
757
 
742
- function quoteShellArgument(value) {
743
- return `'${String(value).replace(/'/g, `'"'"'`)}'`;
744
- }
745
-
746
758
  function redeemCommand(profileName) {
747
759
  return [
748
760
  'npx --package @notis_ai/cli@latest -- notis',
@@ -787,7 +799,7 @@ export async function ensureFreshOAuthCredential(runtime, fetchImpl = fetch) {
787
799
  return Boolean(runtime.jwt);
788
800
  }
789
801
 
790
- const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
802
+ const profile = getProfile(loadConfig(), runtime.profileName);
791
803
  assertOAuthApiTarget(runtime, profile);
792
804
  if (!credentialIsExpired({ credentialKind: 'oauth' }, profile)) {
793
805
  updateRuntimeFromOAuthProfile(runtime, profile);
@@ -853,20 +865,29 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
853
865
  }
854
866
 
855
867
  export async function loginWithOAuth(runtime, options = {}, output, fetchImpl = fetch) {
868
+ // A worktree profile is authenticated by the running `./dev.sh`, not by a
869
+ // browser grant. Authorizing over it would replace a scoped test identity
870
+ // with a real account and quietly point local testing at the wrong user.
871
+ // Check before both starting and redeeming authorization: a copy-paste flow
872
+ // may have started before the worktree lease claimed this profile.
873
+ if (runtime.credentialKind === 'worktree') {
874
+ throw oauthError(
875
+ 'oauth_profile_is_dev_managed',
876
+ `Profile "${runtime.profileName}" is managed by ./dev.sh and cannot be authorized in a browser.`,
877
+ [
878
+ {
879
+ command: `notis login --profile ${quoteShellArgument(runtime.profileName === 'default' ? 'personal' : 'default')}`,
880
+ reason: 'Authorize a real account under a different profile name',
881
+ },
882
+ { command: 'notis profile list', reason: 'See the profiles this machine already has' },
883
+ ],
884
+ );
885
+ }
856
886
  if (options.code) {
857
887
  return redeemAuthorizationCode(runtime, String(options.code).trim(), fetchImpl);
858
888
  }
859
- if (
860
- !options.force
861
- && ['desktop', 'worktree'].includes(runtime.credentialKind)
862
- && !credentialIsExpired(runtime, getProfile(runtime.config, runtime.profileName))
863
- ) {
864
- return { desktopFastPath: true, credentialSource: runtime.credentialKind };
865
- }
866
889
 
867
890
  const metadata = await discoverCliOAuth(runtime.apiBase, fetchImpl);
868
- const { verifier, challenge } = createPkce();
869
- const state = base64url(randomBytes(32));
870
891
  const scopes = options.scope?.length
871
892
  ? [...new Set(options.scope)]
872
893
  : DEFAULT_CLI_OAUTH_SCOPES;
@@ -882,6 +903,44 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
882
903
  let receiver;
883
904
  let redirectUri;
884
905
 
906
+ if (pasteCode) {
907
+ const pending = readPendingAuthorization(runtime);
908
+ const pendingScopes = Array.isArray(pending?.scopes) && pending.scopes.length > 0
909
+ ? pending.scopes
910
+ : DEFAULT_CLI_OAUTH_SCOPES;
911
+ const sameAuthorization = Boolean(
912
+ pending
913
+ && pending.state
914
+ && pending.api_base === runtime.apiBase
915
+ && pending.issuer === metadata.issuer
916
+ && pending.resource === metadata.resource
917
+ && pending.client_id === metadata.clientId
918
+ && pending.token_endpoint === metadata.tokenEndpoint
919
+ && pending.redirect_uri === metadata.copyPasteRedirectUri
920
+ && JSON.stringify(pendingScopes) === JSON.stringify(scopes),
921
+ );
922
+ if (sameAuthorization) {
923
+ const challenge = createHash('sha256')
924
+ .update(pending.verifier, 'ascii')
925
+ .digest('base64url');
926
+ return {
927
+ agentAuthorization: {
928
+ authorize_url: buildAuthorizeUrl(metadata, {
929
+ redirectUri: pending.redirect_uri,
930
+ challenge,
931
+ state: pending.state,
932
+ scopes: pendingScopes,
933
+ }),
934
+ expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
935
+ redeem_command: redeemCommand(runtime.profileName),
936
+ },
937
+ };
938
+ }
939
+ }
940
+
941
+ const { verifier, challenge } = createPkce();
942
+ const state = base64url(randomBytes(32));
943
+
885
944
  if (pasteCode) {
886
945
  redirectUri = metadata.copyPasteRedirectUri;
887
946
  if (!redirectUri) {
@@ -921,6 +980,8 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
921
980
  resource: metadata.resource,
922
981
  client_id: metadata.clientId,
923
982
  token_endpoint: metadata.tokenEndpoint,
983
+ authorization_endpoint: metadata.authorizationEndpoint,
984
+ scopes,
924
985
  expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
925
986
  });
926
987
  }
@@ -979,7 +1040,7 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
979
1040
  return true;
980
1041
  } catch (error) {
981
1042
  if (error?.code !== 'EEXIST') throw error;
982
- const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
1043
+ const profile = getProfile(loadConfig(), runtime.profileName);
983
1044
  if (
984
1045
  profile.oauth_access_token
985
1046
  && profile.oauth_access_token !== runtime.oauthAccessToken
@@ -1005,10 +1066,11 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
1005
1066
  }
1006
1067
 
1007
1068
  export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1008
- const ownsLock = await acquireRefreshLock(runtime);
1009
- if (!ownsLock) return true;
1069
+ let ownsLock = false;
1010
1070
  try {
1011
- const config = loadConfig(runtime.worktreeRuntime);
1071
+ ownsLock = await acquireRefreshLock(runtime);
1072
+ if (!ownsLock) return true;
1073
+ const config = loadConfig();
1012
1074
  const profile = getProfile(config, runtime.profileName);
1013
1075
  assertOAuthApiTarget(runtime, profile);
1014
1076
  if (
@@ -1041,17 +1103,46 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1041
1103
  const updated = persistOAuthTokenResponse(runtime, metadata, response);
1042
1104
  updateRuntimeFromOAuthProfile(runtime, updated);
1043
1105
  return true;
1106
+ } catch (error) {
1107
+ if (error instanceof CliError) {
1108
+ throw new CliError({
1109
+ code: error.code,
1110
+ message: error.message,
1111
+ exitCode: error.exitCode,
1112
+ retryable: error.retryable,
1113
+ details: error.details,
1114
+ hints: getAuthRecovery(runtime).hints,
1115
+ warnings: error.warnings,
1116
+ cause: error,
1117
+ });
1118
+ }
1119
+ throw error;
1044
1120
  } finally {
1045
- try {
1046
- rmdirSync(OAUTH_LOCK_DIR);
1047
- } catch {
1048
- // A process exit or external cleanup may already have removed the lock.
1121
+ if (ownsLock) {
1122
+ try {
1123
+ rmdirSync(OAUTH_LOCK_DIR);
1124
+ } catch {
1125
+ // A process exit or external cleanup may already have removed the lock.
1126
+ }
1049
1127
  }
1050
1128
  }
1051
1129
  }
1052
1130
 
1053
1131
  export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchImpl = fetch) {
1054
- const config = loadConfig(runtime.worktreeRuntime);
1132
+ if (runtime.credentialKind === 'worktree' && !allProfiles) {
1133
+ throw oauthError(
1134
+ 'oauth_profile_is_dev_managed',
1135
+ `Profile "${runtime.profileName}" is managed by ./dev.sh and has no OAuth grant to remove.`,
1136
+ [
1137
+ {
1138
+ command: 'notis logout --profile <name>',
1139
+ reason: 'Name a stored OAuth profile to disconnect it',
1140
+ },
1141
+ { command: 'notis profile list', reason: 'See the stored account profiles on this machine' },
1142
+ ],
1143
+ );
1144
+ }
1145
+ const config = loadConfig();
1055
1146
  const profileNames = allProfiles
1056
1147
  ? Object.keys(config.profiles)
1057
1148
  : [runtime.profileName];
@@ -1100,6 +1191,6 @@ export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchIm
1100
1191
  };
1101
1192
  }
1102
1193
  return latest;
1103
- }, runtime.worktreeRuntime);
1194
+ });
1104
1195
  return { profiles: profileNames };
1105
1196
  }