@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.
- package/README.md +73 -9
- package/dist/scaffolds.json +1 -1
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +3 -3
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +62 -19
- 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 +18 -10
- package/src/command-specs/auth.js +15 -20
- package/src/command-specs/diagnostics.js +6 -1
- package/src/command-specs/helpers.js +7 -1
- 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/cli-mode.generated.js +4 -3
- package/src/runtime/cli-mode.js +13 -8
- package/src/runtime/oauth.js +121 -30
- package/src/runtime/profiles.js +435 -223
- 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,
|
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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(
|
|
714
|
+
return `${resolveConfigFile()}.pending-login.${profileKey}`;
|
|
699
715
|
}
|
|
700
716
|
|
|
701
717
|
function legacyPendingAuthorizationFile(runtime) {
|
|
702
|
-
return `${resolveConfigFile(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1009
|
-
if (!ownsLock) return true;
|
|
1069
|
+
let ownsLock = false;
|
|
1010
1070
|
try {
|
|
1011
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
1194
|
+
});
|
|
1104
1195
|
return { profiles: profileNames };
|
|
1105
1196
|
}
|