@notis_ai/cli 0.2.10 → 0.2.12

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,
@@ -42,14 +43,15 @@ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
42
43
  // still have to sign up, verify an email, and consent before pasting the code.
43
44
  const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
44
45
  const OAUTH_HTTP_TIMEOUT_MS = 10_000;
46
+ const RESPONSE_FLUSH_GRACE_MS = 2_000;
45
47
 
46
- function oauthError(code, message, details = {}) {
48
+ function oauthError(code, message, hints = null, details = {}) {
47
49
  return new CliError({
48
50
  code,
49
51
  message,
50
52
  exitCode: EXIT_CODES.auth,
51
53
  details,
52
- hints: [
54
+ hints: hints || [
53
55
  { command: 'notis login', reason: 'Start a new browser authorization' },
54
56
  { command: 'notis doctor', reason: 'Inspect the active credential state' },
55
57
  ],
@@ -82,6 +84,7 @@ async function fetchJson(url, init = {}, fetchImpl = fetch) {
82
84
  throw oauthError(
83
85
  payload.error || 'oauth_request_failed',
84
86
  payload.error_description || payload.message || `OAuth request failed with status ${response.status}`,
87
+ null,
85
88
  payload,
86
89
  );
87
90
  }
@@ -459,11 +462,22 @@ export async function createLoopbackReceiver({
459
462
  let rejectCode;
460
463
  let timeout;
461
464
  let pendingResponse = null;
465
+ // Browsers routinely park speculative connections that never send a request.
466
+ // `server.close()` waits for every socket it accepted, so the sockets have to
467
+ // be tracked and dropped by hand or a finished login would keep waiting.
468
+ const sockets = new Set();
469
+ let responseFlushed = Promise.resolve();
462
470
  const result = new Promise((resolve, reject) => {
463
471
  resolveCode = resolve;
464
472
  rejectCode = reject;
465
473
  });
466
474
 
475
+ const endResponse = (response, body) => {
476
+ responseFlushed = new Promise((resolve) => {
477
+ response.end(body, resolve);
478
+ });
479
+ };
480
+
467
481
  const server = createServer((request, response) => {
468
482
  const address = server.address();
469
483
  const expectedHost = address && typeof address === 'object'
@@ -476,18 +490,18 @@ export async function createLoopbackReceiver({
476
490
 
477
491
  if (request.headers.host !== expectedHost) {
478
492
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
479
- response.end(callbackHtml('Invalid callback', 'The callback host was not accepted.'));
493
+ endResponse(response, callbackHtml('Invalid callback', 'The callback host was not accepted.'));
480
494
  return;
481
495
  }
482
496
  const url = new URL(request.url || '/', `http://${expectedHost}`);
483
497
  if (request.method !== 'GET' || url.pathname !== '/callback') {
484
498
  response.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
485
- response.end(callbackHtml('Not found', 'This callback path does not exist.'));
499
+ endResponse(response, callbackHtml('Not found', 'This callback path does not exist.'));
486
500
  return;
487
501
  }
488
502
  if (consumed) {
489
503
  response.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
490
- response.end(callbackHtml('Already used', 'This authorization callback was already handled.'));
504
+ endResponse(response, callbackHtml('Already used', 'This authorization callback was already handled.'));
491
505
  return;
492
506
  }
493
507
  consumed = true;
@@ -497,14 +511,14 @@ export async function createLoopbackReceiver({
497
511
  const error = url.searchParams.get('error');
498
512
  if (!stateMatches(state, returnedState)) {
499
513
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
500
- response.end(callbackHtml('Authorization failed', 'The callback state did not match.'));
514
+ endResponse(response, callbackHtml('Authorization failed', 'The callback state did not match.'));
501
515
  rejectCode(oauthError('oauth_state_mismatch', 'The OAuth callback state did not match.'));
502
516
  return;
503
517
  }
504
518
  if (error || !code) {
505
519
  const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
506
520
  response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
507
- response.end(callbackHtml('Authorization not completed', description));
521
+ endResponse(response, callbackHtml('Authorization not completed', description));
508
522
  rejectCode(oauthError(error || 'oauth_code_missing', description));
509
523
  return;
510
524
  }
@@ -515,6 +529,11 @@ export async function createLoopbackReceiver({
515
529
  resolveCode(code);
516
530
  });
517
531
 
532
+ server.on('connection', (socket) => {
533
+ sockets.add(socket);
534
+ socket.on('close', () => sockets.delete(socket));
535
+ });
536
+
518
537
  await new Promise((resolve, reject) => {
519
538
  server.once('error', reject);
520
539
  server.listen(0, '127.0.0.1', () => {
@@ -537,12 +556,24 @@ export async function createLoopbackReceiver({
537
556
  clearTimeout(timeout);
538
557
  if (pendingResponse && !pendingResponse.writableEnded) {
539
558
  pendingResponse.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
540
- pendingResponse.end(callbackHtml(
559
+ endResponse(pendingResponse, callbackHtml(
541
560
  'Authorization did not finish',
542
561
  'Return to the terminal for details, then retry sign in.',
543
562
  ));
544
563
  pendingResponse = null;
545
564
  }
565
+ // The browser answer is already written; wait only for the kernel to take
566
+ // it so tearing the socket down cannot truncate the connected page.
567
+ await Promise.race([
568
+ responseFlushed,
569
+ new Promise((resolve) => { setTimeout(resolve, RESPONSE_FLUSH_GRACE_MS).unref?.(); }),
570
+ ]);
571
+ // Chrome parks a speculative connection next to the one that carried the
572
+ // callback. It never sends a request, so Node counts it as active and
573
+ // `server.close()` waits for a socket only the browser will ever release:
574
+ // a login that already succeeded would sit in the terminal for minutes.
575
+ for (const socket of sockets) socket.destroy();
576
+ sockets.clear();
546
577
  if (!server.listening) return;
547
578
  await new Promise((resolve) => server.close(resolve));
548
579
  };
@@ -561,13 +592,13 @@ export async function createLoopbackReceiver({
561
592
  'Content-Type': 'text/html; charset=utf-8',
562
593
  Location: connectedUrl.toString(),
563
594
  });
564
- pendingResponse.end(connectedCallbackHtml({ portalOrigin }));
595
+ endResponse(pendingResponse, connectedCallbackHtml({ portalOrigin }));
565
596
  pendingResponse = null;
566
597
  },
567
598
  fail: () => {
568
599
  if (!pendingResponse || pendingResponse.writableEnded) return;
569
600
  pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
570
- pendingResponse.end(callbackHtml(
601
+ endResponse(pendingResponse, callbackHtml(
571
602
  'Authorization failed',
572
603
  'The CLI could not finish signing in. Return to the terminal for details.',
573
604
  ));
@@ -682,13 +713,11 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
682
713
  const profile = next.profiles[runtime.profileName];
683
714
  next.profiles[runtime.profileName] = {
684
715
  ...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,
716
+ // The grant defines this profile's endpoint. A profile is one account on
717
+ // one API, and the environment the user just authorized against is the
718
+ // only endpoint the resulting token is accepted by.
719
+ api_base: oauthApiBase || profile.api_base,
720
+ beta: beta ?? profile.beta,
692
721
  oauth_api_base: oauthApiBase || profile.oauth_api_base,
693
722
  oauth_resource: metadata.resource,
694
723
  oauth_access_token: tokenResponse.access_token,
@@ -702,7 +731,7 @@ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
702
731
  oauth_user_id: payload.sub || payload.notis_user_id,
703
732
  };
704
733
  return next;
705
- }, runtime.worktreeRuntime);
734
+ });
706
735
  return config.profiles[runtime.profileName];
707
736
  }
708
737
 
@@ -711,11 +740,11 @@ function pendingAuthorizationFile(runtime) {
711
740
  .update(String(runtime.profileName || 'default'))
712
741
  .digest('hex')
713
742
  .slice(0, 16);
714
- return `${resolveConfigFile(runtime)}.pending-login.${profileKey}`;
743
+ return `${resolveConfigFile()}.pending-login.${profileKey}`;
715
744
  }
716
745
 
717
746
  function legacyPendingAuthorizationFile(runtime) {
718
- return `${resolveConfigFile(runtime)}.pending-login`;
747
+ return `${resolveConfigFile()}.pending-login`;
719
748
  }
720
749
 
721
750
  // The PKCE verifier outlives the process that created it whenever the browser
@@ -755,10 +784,6 @@ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runt
755
784
  }
756
785
  }
757
786
 
758
- function quoteShellArgument(value) {
759
- return `'${String(value).replace(/'/g, `'"'"'`)}'`;
760
- }
761
-
762
787
  function redeemCommand(profileName) {
763
788
  return [
764
789
  'npx --package @notis_ai/cli@latest -- notis',
@@ -803,7 +828,7 @@ export async function ensureFreshOAuthCredential(runtime, fetchImpl = fetch) {
803
828
  return Boolean(runtime.jwt);
804
829
  }
805
830
 
806
- const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
831
+ const profile = getProfile(loadConfig(), runtime.profileName);
807
832
  assertOAuthApiTarget(runtime, profile);
808
833
  if (!credentialIsExpired({ credentialKind: 'oauth' }, profile)) {
809
834
  updateRuntimeFromOAuthProfile(runtime, profile);
@@ -869,20 +894,29 @@ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
869
894
  }
870
895
 
871
896
  export async function loginWithOAuth(runtime, options = {}, output, fetchImpl = fetch) {
897
+ // A worktree profile is authenticated by the running `./dev.sh`, not by a
898
+ // browser grant. Authorizing over it would replace a scoped test identity
899
+ // with a real account and quietly point local testing at the wrong user.
900
+ // Check before both starting and redeeming authorization: a copy-paste flow
901
+ // may have started before the worktree lease claimed this profile.
902
+ if (runtime.credentialKind === 'worktree') {
903
+ throw oauthError(
904
+ 'oauth_profile_is_dev_managed',
905
+ `Profile "${runtime.profileName}" is managed by ./dev.sh and cannot be authorized in a browser.`,
906
+ [
907
+ {
908
+ command: `notis login --profile ${quoteShellArgument(runtime.profileName === 'default' ? 'personal' : 'default')}`,
909
+ reason: 'Authorize a real account under a different profile name',
910
+ },
911
+ { command: 'notis profile list', reason: 'See the profiles this machine already has' },
912
+ ],
913
+ );
914
+ }
872
915
  if (options.code) {
873
916
  return redeemAuthorizationCode(runtime, String(options.code).trim(), fetchImpl);
874
917
  }
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
918
 
883
919
  const metadata = await discoverCliOAuth(runtime.apiBase, fetchImpl);
884
- const { verifier, challenge } = createPkce();
885
- const state = base64url(randomBytes(32));
886
920
  const scopes = options.scope?.length
887
921
  ? [...new Set(options.scope)]
888
922
  : DEFAULT_CLI_OAUTH_SCOPES;
@@ -898,6 +932,44 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
898
932
  let receiver;
899
933
  let redirectUri;
900
934
 
935
+ if (pasteCode) {
936
+ const pending = readPendingAuthorization(runtime);
937
+ const pendingScopes = Array.isArray(pending?.scopes) && pending.scopes.length > 0
938
+ ? pending.scopes
939
+ : DEFAULT_CLI_OAUTH_SCOPES;
940
+ const sameAuthorization = Boolean(
941
+ pending
942
+ && pending.state
943
+ && pending.api_base === runtime.apiBase
944
+ && pending.issuer === metadata.issuer
945
+ && pending.resource === metadata.resource
946
+ && pending.client_id === metadata.clientId
947
+ && pending.token_endpoint === metadata.tokenEndpoint
948
+ && pending.redirect_uri === metadata.copyPasteRedirectUri
949
+ && JSON.stringify(pendingScopes) === JSON.stringify(scopes),
950
+ );
951
+ if (sameAuthorization) {
952
+ const challenge = createHash('sha256')
953
+ .update(pending.verifier, 'ascii')
954
+ .digest('base64url');
955
+ return {
956
+ agentAuthorization: {
957
+ authorize_url: buildAuthorizeUrl(metadata, {
958
+ redirectUri: pending.redirect_uri,
959
+ challenge,
960
+ state: pending.state,
961
+ scopes: pendingScopes,
962
+ }),
963
+ expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
964
+ redeem_command: redeemCommand(runtime.profileName),
965
+ },
966
+ };
967
+ }
968
+ }
969
+
970
+ const { verifier, challenge } = createPkce();
971
+ const state = base64url(randomBytes(32));
972
+
901
973
  if (pasteCode) {
902
974
  redirectUri = metadata.copyPasteRedirectUri;
903
975
  if (!redirectUri) {
@@ -937,6 +1009,8 @@ export async function loginWithOAuth(runtime, options = {}, output, fetchImpl =
937
1009
  resource: metadata.resource,
938
1010
  client_id: metadata.clientId,
939
1011
  token_endpoint: metadata.tokenEndpoint,
1012
+ authorization_endpoint: metadata.authorizationEndpoint,
1013
+ scopes,
940
1014
  expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
941
1015
  });
942
1016
  }
@@ -995,7 +1069,7 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
995
1069
  return true;
996
1070
  } catch (error) {
997
1071
  if (error?.code !== 'EEXIST') throw error;
998
- const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
1072
+ const profile = getProfile(loadConfig(), runtime.profileName);
999
1073
  if (
1000
1074
  profile.oauth_access_token
1001
1075
  && profile.oauth_access_token !== runtime.oauthAccessToken
@@ -1021,10 +1095,11 @@ async function acquireRefreshLock(runtime, waitMs = 60_000) {
1021
1095
  }
1022
1096
 
1023
1097
  export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1024
- const ownsLock = await acquireRefreshLock(runtime);
1025
- if (!ownsLock) return true;
1098
+ let ownsLock = false;
1026
1099
  try {
1027
- const config = loadConfig(runtime.worktreeRuntime);
1100
+ ownsLock = await acquireRefreshLock(runtime);
1101
+ if (!ownsLock) return true;
1102
+ const config = loadConfig();
1028
1103
  const profile = getProfile(config, runtime.profileName);
1029
1104
  assertOAuthApiTarget(runtime, profile);
1030
1105
  if (
@@ -1057,17 +1132,46 @@ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
1057
1132
  const updated = persistOAuthTokenResponse(runtime, metadata, response);
1058
1133
  updateRuntimeFromOAuthProfile(runtime, updated);
1059
1134
  return true;
1135
+ } catch (error) {
1136
+ if (error instanceof CliError) {
1137
+ throw new CliError({
1138
+ code: error.code,
1139
+ message: error.message,
1140
+ exitCode: error.exitCode,
1141
+ retryable: error.retryable,
1142
+ details: error.details,
1143
+ hints: getAuthRecovery(runtime).hints,
1144
+ warnings: error.warnings,
1145
+ cause: error,
1146
+ });
1147
+ }
1148
+ throw error;
1060
1149
  } finally {
1061
- try {
1062
- rmdirSync(OAUTH_LOCK_DIR);
1063
- } catch {
1064
- // A process exit or external cleanup may already have removed the lock.
1150
+ if (ownsLock) {
1151
+ try {
1152
+ rmdirSync(OAUTH_LOCK_DIR);
1153
+ } catch {
1154
+ // A process exit or external cleanup may already have removed the lock.
1155
+ }
1065
1156
  }
1066
1157
  }
1067
1158
  }
1068
1159
 
1069
1160
  export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchImpl = fetch) {
1070
- const config = loadConfig(runtime.worktreeRuntime);
1161
+ if (runtime.credentialKind === 'worktree' && !allProfiles) {
1162
+ throw oauthError(
1163
+ 'oauth_profile_is_dev_managed',
1164
+ `Profile "${runtime.profileName}" is managed by ./dev.sh and has no OAuth grant to remove.`,
1165
+ [
1166
+ {
1167
+ command: 'notis logout --profile <name>',
1168
+ reason: 'Name a stored OAuth profile to disconnect it',
1169
+ },
1170
+ { command: 'notis profile list', reason: 'See the stored account profiles on this machine' },
1171
+ ],
1172
+ );
1173
+ }
1174
+ const config = loadConfig();
1071
1175
  const profileNames = allProfiles
1072
1176
  ? Object.keys(config.profiles)
1073
1177
  : [runtime.profileName];
@@ -1116,6 +1220,6 @@ export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchIm
1116
1220
  };
1117
1221
  }
1118
1222
  return latest;
1119
- }, runtime.worktreeRuntime);
1223
+ });
1120
1224
  return { profiles: profileNames };
1121
1225
  }