@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.
- 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 +111 -36
- 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,
|
|
@@ -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
|
-
//
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
}
|
|
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(
|
|
714
|
+
return `${resolveConfigFile()}.pending-login.${profileKey}`;
|
|
715
715
|
}
|
|
716
716
|
|
|
717
717
|
function legacyPendingAuthorizationFile(runtime) {
|
|
718
|
-
return `${resolveConfigFile(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1025
|
-
if (!ownsLock) return true;
|
|
1069
|
+
let ownsLock = false;
|
|
1026
1070
|
try {
|
|
1027
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
1194
|
+
});
|
|
1120
1195
|
return { profiles: profileNames };
|
|
1121
1196
|
}
|