@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.
- package/README.md +73 -9
- package/dist/scaffolds.json +1 -1
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +1 -1
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +56 -16
- package/skills/notis-onboarding/BRIEF.md +24 -5
- package/skills/notis-query/cli.md +1 -1
- package/src/cli.js +27 -2
- package/src/command-specs/apps.js +7 -29
- package/src/command-specs/auth.js +15 -20
- package/src/command-specs/index.js +3 -0
- package/src/command-specs/meta.js +29 -19
- package/src/command-specs/onboarding.js +122 -200
- package/src/command-specs/profile.js +358 -0
- package/src/runtime/app-platform.js +5 -5
- package/src/runtime/auth-recovery.js +100 -0
- package/src/runtime/oauth.js +148 -44
- package/src/runtime/profiles.js +395 -221
- package/src/runtime/transport.js +84 -29
- package/src/runtime/desktop-auth.js +0 -162
package/src/runtime/oauth.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
}
|
|
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(
|
|
743
|
+
return `${resolveConfigFile()}.pending-login.${profileKey}`;
|
|
715
744
|
}
|
|
716
745
|
|
|
717
746
|
function legacyPendingAuthorizationFile(runtime) {
|
|
718
|
-
return `${resolveConfigFile(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1025
|
-
if (!ownsLock) return true;
|
|
1098
|
+
let ownsLock = false;
|
|
1026
1099
|
try {
|
|
1027
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
1223
|
+
});
|
|
1120
1224
|
return { profiles: profileNames };
|
|
1121
1225
|
}
|