@notis_ai/cli 0.2.10 → 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
  }
@@ -682,13 +684,11 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
682
684
  const profile = next.profiles[runtime.profileName];
683
685
  next.profiles[runtime.profileName] = {
684
686
  ...profile,
685
- // Keep Desktop's api_base intact when an independent OAuth grant refreshes.
686
- // Only seed api_base from OAuth when the profile has no live API yet.
687
- api_base:
688
- profile.api_base && !/^https?:\/\/(localhost|127\.0\.0\.1|::1)(:|\/|$)/i.test(profile.api_base)
689
- ? profile.api_base
690
- : (oauthApiBase || profile.api_base),
691
- beta: typeof profile.beta === 'boolean' ? profile.beta : beta,
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
692
  oauth_api_base: oauthApiBase || profile.oauth_api_base,
693
693
  oauth_resource: metadata.resource,
694
694
  oauth_access_token: tokenResponse.access_token,
@@ -702,7 +702,7 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
702
702
  oauth_user_id: payload.sub || payload.notis_user_id,
703
703
  };
704
704
  return next;
705
- }, runtime.worktreeRuntime);
705
+ });
706
706
  return config.profiles[runtime.profileName];
707
707
  }
708
708
 
@@ -711,11 +711,11 @@ function pendingAuthorizationFile(runtime) {
711
711
  .update(String(runtime.profileName || 'default'))
712
712
  .digest('hex')
713
713
  .slice(0, 16);
714
- return `${resolveConfigFile(runtime)}.pending-login.${profileKey}`;
714
+ return `${resolveConfigFile()}.pending-login.${profileKey}`;
715
715
  }
716
716
 
717
717
  function legacyPendingAuthorizationFile(runtime) {
718
- return `${resolveConfigFile(runtime)}.pending-login`;
718
+ return `${resolveConfigFile()}.pending-login`;
719
719
  }
720
720
 
721
721
  // The PKCE verifier outlives the process that created it whenever the browser
@@ -755,10 +755,6 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
755
755
  }
756
756
  }
757
757
 
758
- function quoteShellArgument(value) {
759
- return `'${String(value).replace(/'/g, `'"'"'`)}'`;
760
- }
761
-
762
758
  function redeemCommand(profileName) {
763
759
  return [
764
760
  'npx --package @notis_ai/cli@latest -- notis',
@@ -803,7 +799,7 @@ export async function ensureFreshOAuthCredential(runtime, fetchImpl = fetch) {
803
799
  return Boolean(runtime.jwt);
804
800
  }
805
801
 
806
- const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
802
+ const profile = getProfile(loadConfig(), runtime.profileName);
807
803
  assertOAuthApiTarget(runtime, profile);
808
804
  if (!credentialIsExpired({ credentialKind: 'oauth' }, profile)) {
809
805
  updateRuntimeFromOAuthProfile(runtime, profile);
@@ -869,20 +865,29 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
869
865
  }
870
866
 
871
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
+ }
872
886
  if (options.code) {
873
887
  return redeemAuthorizationCode(runtime, String(options.code).trim(), fetchImpl);
874
888
  }
875
- if (
876
- !options.force
877
- && ['desktop', 'worktree'].includes(runtime.credentialKind)
878
- && !credentialIsExpired(runtime, getProfile(runtime.config, runtime.profileName))
879
- ) {
880
- return { desktopFastPath: true, credentialSource: runtime.credentialKind };
881
- }
882
889
 
883
890
  const metadata = await discoverCliOAuth(runtime.apiBase, fetchImpl);
884
- const { verifier, challenge } = createPkce();
885
- const state = base64url(randomBytes(32));
886
891
  const scopes = options.scope?.length
887
892
  ? [...new Set(options.scope)]
888
893
  : DEFAULT_CLI_OAUTH_SCOPES;
@@ -898,6 +903,44 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
898
903
  let receiver;
899
904
  let redirectUri;
900
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
+
901
944
  if (pasteCode) {
902
945
  redirectUri = metadata.copyPasteRedirectUri;
903
946
  if (!redirectUri) {
@@ -937,6 +980,8 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
937
980
  resource: metadata.resource,
938
981
  client_id: metadata.clientId,
939
982
  token_endpoint: metadata.tokenEndpoint,
983
+ authorization_endpoint: metadata.authorizationEndpoint,
984
+ scopes,
940
985
  expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
941
986
  });
942
987
  }
@@ -995,7 +1040,7 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
995
1040
  return true;
996
1041
  } catch (error) {
997
1042
  if (error?.code !== 'EEXIST') throw error;
998
- const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
1043
+ const profile = getProfile(loadConfig(), runtime.profileName);
999
1044
  if (
1000
1045
  profile.oauth_access_token
1001
1046
  && profile.oauth_access_token !== runtime.oauthAccessToken
@@ -1021,10 +1066,11 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
1021
1066
  }
1022
1067
 
1023
1068
  export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1024
- const ownsLock = await acquireRefreshLock(runtime);
1025
- if (!ownsLock) return true;
1069
+ let ownsLock = false;
1026
1070
  try {
1027
- const config = loadConfig(runtime.worktreeRuntime);
1071
+ ownsLock = await acquireRefreshLock(runtime);
1072
+ if (!ownsLock) return true;
1073
+ const config = loadConfig();
1028
1074
  const profile = getProfile(config, runtime.profileName);
1029
1075
  assertOAuthApiTarget(runtime, profile);
1030
1076
  if (
@@ -1057,17 +1103,46 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1057
1103
  const updated = persistOAuthTokenResponse(runtime, metadata, response);
1058
1104
  updateRuntimeFromOAuthProfile(runtime, updated);
1059
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;
1060
1120
  } finally {
1061
- try {
1062
- rmdirSync(OAUTH_LOCK_DIR);
1063
- } catch {
1064
- // 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
+ }
1065
1127
  }
1066
1128
  }
1067
1129
  }
1068
1130
 
1069
1131
  export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchImpl = fetch) {
1070
- 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();
1071
1146
  const profileNames = allProfiles
1072
1147
  ? Object.keys(config.profiles)
1073
1148
  : [runtime.profileName];
@@ -1116,6 +1191,6 @@ export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchIm
1116
1191
  };
1117
1192
  }
1118
1193
  return latest;
1119
- }, runtime.worktreeRuntime);
1194
+ });
1120
1195
  return { profiles: profileNames };
1121
1196
  }