@notis_ai/cli 0.2.7 → 0.2.9
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 +56 -2
- package/dist/scaffolds/notis-database/app/page.tsx +38 -41
- package/dist/scaffolds/notis-database/lib/types.ts +5 -7
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +1839 -0
- package/dist/scaffolds/notis-database/notis.config.ts +48 -1
- package/dist/scaffolds/notis-database/package-lock.json +3935 -0
- package/dist/scaffolds/notis-database/package.json +2 -1
- package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/package.json +4 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +60 -0
- package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +196 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +229 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/index.ts +30 -1
- package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/runtime.ts +76 -17
- package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +186 -0
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +4 -1
- package/dist/scaffolds/notis-journal/CHANGELOG.md +4 -0
- package/dist/scaffolds/notis-journal/app/page.tsx +1 -0
- package/dist/scaffolds/notis-journal/package-lock.json +4615 -0
- package/dist/scaffolds/notis-journal/package.json +2 -2
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +52 -1
- package/dist/scaffolds/notis-notes/app/page.tsx +39 -46
- package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +596 -0
- package/dist/scaffolds/notis-notes/lib/visible-properties.ts +144 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +752 -0
- package/dist/scaffolds/notis-notes/notis.config.ts +48 -3
- package/dist/scaffolds/notis-notes/package-lock.json +4636 -0
- package/dist/scaffolds/notis-notes/package.json +7 -3
- package/dist/scaffolds/notis-notes/packages/sdk/package.json +36 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +60 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +196 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +229 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +64 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +56 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +83 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +43 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +220 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +186 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +15 -0
- package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +56 -0
- package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +15 -0
- package/dist/scaffolds/notis-random/CHANGELOG.md +12 -2
- package/dist/scaffolds/notis-random/app/history/page.tsx +5 -4
- package/dist/scaffolds/notis-random/app/page.tsx +141 -74
- package/dist/scaffolds/notis-random/components/ui/button.tsx +50 -0
- package/dist/scaffolds/notis-random/components/ui/card.tsx +16 -0
- package/dist/scaffolds/notis-random/components/ui/input.tsx +23 -0
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +26 -7
- package/dist/scaffolds/notis-random/lib/rng.ts +179 -19
- package/dist/scaffolds/notis-random/lib/roll-record.ts +103 -16
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +753 -0
- package/dist/scaffolds/notis-random/notis.config.ts +47 -3
- package/dist/scaffolds/notis-random/package-lock.json +4513 -0
- package/dist/scaffolds/notis-random/package.json +6 -2
- package/dist/scaffolds/notis-random/packages/sdk/package.json +36 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +93 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +60 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +196 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +229 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +41 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +78 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +121 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +34 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +64 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +56 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +83 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +43 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +220 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +186 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +15 -0
- package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +56 -0
- package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +15 -0
- package/dist/scaffolds/notis-random/tailwind.config.ts +8 -1
- package/dist/scaffolds.json +8 -17
- package/package.json +2 -1
- package/skills/notis-apps/SKILL.md +438 -144
- package/skills/notis-apps/cli.md +16 -1
- package/skills/notis-cli/SKILL.md +11 -4
- package/skills/notis-onboarding/BRIEF.md +93 -0
- package/skills/notis-query/cli.md +1 -1
- package/src/cli.js +50 -12
- package/src/command-specs/apps.js +309 -41
- package/src/command-specs/auth.js +107 -0
- package/src/command-specs/index.js +4 -0
- package/src/command-specs/meta.js +68 -6
- package/src/command-specs/onboarding.js +304 -0
- package/src/command-specs/tools.js +8 -4
- package/src/runtime/app-dev-server.js +3 -2
- package/src/runtime/app-dev-sessions.js +14 -40
- package/src/runtime/app-platform.js +65 -4
- package/src/runtime/desktop-auth.js +86 -17
- package/src/runtime/errors.js +1 -0
- package/src/runtime/oauth.js +1105 -0
- package/src/runtime/output.js +16 -2
- package/src/runtime/profiles.js +542 -18
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +66 -11
- package/template/.harness/index.html.tmpl +30 -2
- package/template/packages/sdk/src/config.ts +38 -1
- package/dist/scaffolds/notis-affiliate-prospects/CHANGELOG.md +0 -7
- package/dist/scaffolds/notis-affiliate-prospects/README.md +0 -20
- package/dist/scaffolds/notis-affiliate-prospects/app/globals.css +0 -42
- package/dist/scaffolds/notis-affiliate-prospects/app/layout.tsx +0 -7
- package/dist/scaffolds/notis-affiliate-prospects/app/page.tsx +0 -248
- package/dist/scaffolds/notis-affiliate-prospects/app/prospects/page.tsx +0 -134
- package/dist/scaffolds/notis-affiliate-prospects/app/segments/page.tsx +0 -108
- package/dist/scaffolds/notis-affiliate-prospects/components/ui/card.tsx +0 -35
- package/dist/scaffolds/notis-affiliate-prospects/index.html +0 -12
- package/dist/scaffolds/notis-affiliate-prospects/lib/affiliate-data.ts +0 -218
- package/dist/scaffolds/notis-affiliate-prospects/lib/demo-prospects.ts +0 -52
- package/dist/scaffolds/notis-affiliate-prospects/lib/notis-tools.ts +0 -100
- package/dist/scaffolds/notis-affiliate-prospects/lib/utils.ts +0 -25
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-fixtures.json +0 -187
- package/dist/scaffolds/notis-affiliate-prospects/notis.config.ts +0 -65
- package/dist/scaffolds/notis-affiliate-prospects/package.json +0 -32
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/config.ts +0 -90
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useDatabase.ts +0 -76
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useUpsertDocument.ts +0 -50
- package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/styles.css +0 -38
- package/dist/scaffolds/notis-affiliate-prospects/postcss.config.mjs +0 -6
- package/dist/scaffolds/notis-affiliate-prospects/src/dev-main.tsx +0 -72
- package/dist/scaffolds/notis-affiliate-prospects/src/mock-runtime.ts +0 -242
- package/dist/scaffolds/notis-affiliate-prospects/tailwind.config.ts +0 -51
- package/dist/scaffolds/notis-affiliate-prospects/tsconfig.json +0 -23
- package/dist/scaffolds/notis-affiliate-prospects/vite.config.ts +0 -11
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useBackend.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useNotis.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useNotisNavigation.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTool.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTools.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTopBarSearch.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/provider.tsx +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/ui.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/vite.ts +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/tsconfig.json +0 -0
- /package/dist/scaffolds/{notis-affiliate-prospects → notis-random}/components.json +0 -0
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHash,
|
|
3
|
+
randomBytes,
|
|
4
|
+
timingSafeEqual,
|
|
5
|
+
} from 'node:crypto';
|
|
6
|
+
import {
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { createInterface } from 'node:readline/promises';
|
|
19
|
+
|
|
20
|
+
import { CliError, EXIT_CODES } from './errors.js';
|
|
21
|
+
import {
|
|
22
|
+
credentialIsExpired,
|
|
23
|
+
ensureProfile,
|
|
24
|
+
getOAuthApiBase,
|
|
25
|
+
getOAuthResource,
|
|
26
|
+
getProfile,
|
|
27
|
+
loadConfig,
|
|
28
|
+
resolveConfigFile,
|
|
29
|
+
updateConfig,
|
|
30
|
+
} from './profiles.js';
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_CLI_OAUTH_SCOPES = [
|
|
33
|
+
'notis:read',
|
|
34
|
+
'notis:write',
|
|
35
|
+
'notis:connections',
|
|
36
|
+
'notis:apps',
|
|
37
|
+
];
|
|
38
|
+
const OAUTH_LOCK_DIR = join(homedir(), '.notis', 'oauth.lock');
|
|
39
|
+
const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
|
|
40
|
+
const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
|
|
41
|
+
// A parked authorization outlives the terminal that started it: the user may
|
|
42
|
+
// still have to sign up, verify an email, and consent before pasting the code.
|
|
43
|
+
const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
|
|
44
|
+
const OAUTH_HTTP_TIMEOUT_MS = 10_000;
|
|
45
|
+
|
|
46
|
+
function oauthError(code, message, details = {}) {
|
|
47
|
+
return new CliError({
|
|
48
|
+
code,
|
|
49
|
+
message,
|
|
50
|
+
exitCode: EXIT_CODES.auth,
|
|
51
|
+
details,
|
|
52
|
+
hints: [
|
|
53
|
+
{ command: 'notis login', reason: 'Start a new browser authorization' },
|
|
54
|
+
{ command: 'notis doctor', reason: 'Inspect the active credential state' },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function base64url(value) {
|
|
60
|
+
return Buffer.from(value).toString('base64url');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decodeJwtPayload(token) {
|
|
64
|
+
try {
|
|
65
|
+
const parts = String(token || '').split('.');
|
|
66
|
+
if (parts.length !== 3) return {};
|
|
67
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function fetchJson(url, init = {}, fetchImpl = fetch) {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timeout = setTimeout(() => controller.abort(), OAUTH_HTTP_TIMEOUT_MS);
|
|
76
|
+
const abortFromCaller = () => controller.abort();
|
|
77
|
+
init.signal?.addEventListener?.('abort', abortFromCaller, { once: true });
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetchImpl(url, { ...init, signal: controller.signal });
|
|
80
|
+
const payload = await response.json().catch(() => ({}));
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw oauthError(
|
|
83
|
+
payload.error || 'oauth_request_failed',
|
|
84
|
+
payload.error_description || payload.message || `OAuth request failed with status ${response.status}`,
|
|
85
|
+
payload,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return payload;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (controller.signal.aborted && !init.signal?.aborted) {
|
|
91
|
+
throw oauthError(
|
|
92
|
+
'oauth_request_timeout',
|
|
93
|
+
'The OAuth server did not respond in time. Retry the command.',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(timeout);
|
|
99
|
+
init.signal?.removeEventListener?.('abort', abortFromCaller);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function discoverCliOAuth(apiBase, fetchImpl = fetch) {
|
|
104
|
+
const normalizedApiBase = apiBase.replace(/\/+$/, '');
|
|
105
|
+
const protectedResource = await fetchJson(
|
|
106
|
+
`${normalizedApiBase}/.well-known/oauth-protected-resource/cli`,
|
|
107
|
+
{},
|
|
108
|
+
fetchImpl,
|
|
109
|
+
);
|
|
110
|
+
const issuer = protectedResource.authorization_servers?.[0];
|
|
111
|
+
if (
|
|
112
|
+
typeof protectedResource.resource !== 'string'
|
|
113
|
+
|| !protectedResource.resource
|
|
114
|
+
|| typeof issuer !== 'string'
|
|
115
|
+
|| !issuer
|
|
116
|
+
) {
|
|
117
|
+
throw oauthError('oauth_metadata_invalid', 'Notis returned incomplete CLI OAuth metadata.');
|
|
118
|
+
}
|
|
119
|
+
const authorizationServer = await fetchJson(
|
|
120
|
+
`${issuer.replace(/\/+$/, '')}/.well-known/oauth-authorization-server`,
|
|
121
|
+
{},
|
|
122
|
+
fetchImpl,
|
|
123
|
+
);
|
|
124
|
+
if (
|
|
125
|
+
typeof authorizationServer.authorization_endpoint !== 'string'
|
|
126
|
+
|| typeof authorizationServer.token_endpoint !== 'string'
|
|
127
|
+
|| typeof authorizationServer.revocation_endpoint !== 'string'
|
|
128
|
+
) {
|
|
129
|
+
throw oauthError('oauth_metadata_invalid', 'Notis returned incomplete authorization server metadata.');
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
apiBase: normalizedApiBase,
|
|
133
|
+
issuer,
|
|
134
|
+
resource: protectedResource.resource,
|
|
135
|
+
clientId: protectedResource.notis_cli_client_id || 'notis_cli',
|
|
136
|
+
copyPasteRedirectUri: protectedResource.notis_cli_copy_paste_redirect_uri,
|
|
137
|
+
authorizationEndpoint: authorizationServer.authorization_endpoint,
|
|
138
|
+
tokenEndpoint: authorizationServer.token_endpoint,
|
|
139
|
+
revocationEndpoint: authorizationServer.revocation_endpoint,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createPkce() {
|
|
144
|
+
const verifier = base64url(randomBytes(64));
|
|
145
|
+
const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
|
146
|
+
return { verifier, challenge };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stateMatches(expected, actual) {
|
|
150
|
+
const expectedBuffer = Buffer.from(expected);
|
|
151
|
+
const actualBuffer = Buffer.from(actual || '');
|
|
152
|
+
return (
|
|
153
|
+
expectedBuffer.length === actualBuffer.length
|
|
154
|
+
&& timingSafeEqual(expectedBuffer, actualBuffer)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const DESKTOP_DOWNLOAD_FALLBACK_URL = 'https://notis.ai/channels/desktop-app/#download';
|
|
159
|
+
const DESKTOP_DOWNLOAD_BASE_URL = (
|
|
160
|
+
'https://jhgrvlwivajaifrunqpe.supabase.co/storage/v1/object/public/'
|
|
161
|
+
+ 'desktop-releases/production'
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
function escapeHtml(value) {
|
|
165
|
+
return String(value)
|
|
166
|
+
.replaceAll('&', '&')
|
|
167
|
+
.replaceAll('<', '<')
|
|
168
|
+
.replaceAll('>', '>')
|
|
169
|
+
.replaceAll('"', '"')
|
|
170
|
+
.replaceAll("'", ''');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizePortalOrigin(value) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = new URL(value || 'https://app.notis.ai');
|
|
176
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return 'https://app.notis.ai';
|
|
177
|
+
return parsed.origin;
|
|
178
|
+
} catch {
|
|
179
|
+
return 'https://app.notis.ai';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function callbackHtml(title, detail) {
|
|
184
|
+
return `<!doctype html>
|
|
185
|
+
<html lang="en">
|
|
186
|
+
<head>
|
|
187
|
+
<meta charset="utf-8">
|
|
188
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
189
|
+
<meta name="referrer" content="no-referrer">
|
|
190
|
+
<title>${escapeHtml(title)}</title>
|
|
191
|
+
<style>
|
|
192
|
+
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
193
|
+
* { box-sizing: border-box; }
|
|
194
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #09090b; color: #fafafa; }
|
|
195
|
+
main { width: min(100%, 520px); padding: clamp(24px, 6vw, 40px); border: 1px solid #27272a; border-radius: 22px; background: #111113; box-shadow: 0 24px 80px rgb(0 0 0 / 35%); }
|
|
196
|
+
h1 { margin: 0; font-size: clamp(1.75rem, 6vw, 2.4rem); line-height: 1.08; letter-spacing: -0.035em; }
|
|
197
|
+
p { margin: 14px 0 0; color: #a1a1aa; line-height: 1.6; }
|
|
198
|
+
</style>
|
|
199
|
+
</head>
|
|
200
|
+
<body>
|
|
201
|
+
<main>
|
|
202
|
+
<h1>${escapeHtml(title)}</h1>
|
|
203
|
+
<p>${escapeHtml(detail)}</p>
|
|
204
|
+
</main>
|
|
205
|
+
</body>
|
|
206
|
+
</html>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function connectedCallbackHtml({ portalOrigin }) {
|
|
210
|
+
const safePortalOrigin = normalizePortalOrigin(portalOrigin);
|
|
211
|
+
const webAppUrl = new URL('/manage', `${safePortalOrigin}/`).toString();
|
|
212
|
+
const quickLoginUrl = new URL('/desktop-quick-login', `${safePortalOrigin}/`);
|
|
213
|
+
quickLoginUrl.searchParams.set('redirect', '/manage');
|
|
214
|
+
const serializedDownloads = JSON.stringify({
|
|
215
|
+
fallback: DESKTOP_DOWNLOAD_FALLBACK_URL,
|
|
216
|
+
base: DESKTOP_DOWNLOAD_BASE_URL,
|
|
217
|
+
}).replaceAll('<', '\\u003c');
|
|
218
|
+
|
|
219
|
+
return `<!doctype html>
|
|
220
|
+
<html lang="en">
|
|
221
|
+
<head>
|
|
222
|
+
<meta charset="utf-8">
|
|
223
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
224
|
+
<meta name="referrer" content="no-referrer">
|
|
225
|
+
<title>Notis CLI is connected</title>
|
|
226
|
+
<style>
|
|
227
|
+
:root {
|
|
228
|
+
color-scheme: dark;
|
|
229
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
230
|
+
--background: #08090b;
|
|
231
|
+
--surface: #111317;
|
|
232
|
+
--surface-raised: #171a20;
|
|
233
|
+
--border: #292d35;
|
|
234
|
+
--muted: #a6acb8;
|
|
235
|
+
--primary: #f5f7fa;
|
|
236
|
+
--primary-ink: #111317;
|
|
237
|
+
--success: #72f2ad;
|
|
238
|
+
--focus: #8cb8ff;
|
|
239
|
+
}
|
|
240
|
+
* { box-sizing: border-box; }
|
|
241
|
+
[hidden] { display: none !important; }
|
|
242
|
+
body {
|
|
243
|
+
margin: 0;
|
|
244
|
+
min-height: 100vh;
|
|
245
|
+
display: grid;
|
|
246
|
+
place-items: center;
|
|
247
|
+
padding: clamp(16px, 4vw, 40px);
|
|
248
|
+
background:
|
|
249
|
+
radial-gradient(circle at 50% -10%, rgb(63 80 120 / 20%), transparent 38%),
|
|
250
|
+
var(--background);
|
|
251
|
+
color: #f8fafc;
|
|
252
|
+
}
|
|
253
|
+
main {
|
|
254
|
+
width: min(100%, 680px);
|
|
255
|
+
padding: clamp(24px, 6vw, 44px);
|
|
256
|
+
border: 1px solid var(--border);
|
|
257
|
+
border-radius: 24px;
|
|
258
|
+
background: rgb(17 19 23 / 96%);
|
|
259
|
+
box-shadow: 0 28px 100px rgb(0 0 0 / 42%);
|
|
260
|
+
}
|
|
261
|
+
.brand {
|
|
262
|
+
display: inline-flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
gap: 9px;
|
|
265
|
+
margin-block-end: 30px;
|
|
266
|
+
color: #d9dde5;
|
|
267
|
+
font-size: 0.82rem;
|
|
268
|
+
font-weight: 750;
|
|
269
|
+
letter-spacing: 0.08em;
|
|
270
|
+
text-transform: uppercase;
|
|
271
|
+
}
|
|
272
|
+
.brand-mark {
|
|
273
|
+
display: grid;
|
|
274
|
+
width: 30px;
|
|
275
|
+
aspect-ratio: 1;
|
|
276
|
+
place-items: center;
|
|
277
|
+
border-radius: 9px;
|
|
278
|
+
background: #f4f4f5;
|
|
279
|
+
color: #09090b;
|
|
280
|
+
font-size: 1rem;
|
|
281
|
+
font-weight: 900;
|
|
282
|
+
}
|
|
283
|
+
.status {
|
|
284
|
+
display: inline-flex;
|
|
285
|
+
align-items: center;
|
|
286
|
+
gap: 8px;
|
|
287
|
+
margin-block-end: 14px;
|
|
288
|
+
color: var(--success);
|
|
289
|
+
font-size: 0.78rem;
|
|
290
|
+
font-weight: 800;
|
|
291
|
+
letter-spacing: 0.08em;
|
|
292
|
+
text-transform: uppercase;
|
|
293
|
+
}
|
|
294
|
+
.status-dot {
|
|
295
|
+
width: 8px;
|
|
296
|
+
aspect-ratio: 1;
|
|
297
|
+
border-radius: 999px;
|
|
298
|
+
background: currentColor;
|
|
299
|
+
box-shadow: 0 0 0 5px rgb(114 242 173 / 10%);
|
|
300
|
+
}
|
|
301
|
+
h1 {
|
|
302
|
+
margin: 0;
|
|
303
|
+
max-width: 16ch;
|
|
304
|
+
font-size: clamp(2rem, 7vw, 3.25rem);
|
|
305
|
+
line-height: 1.02;
|
|
306
|
+
letter-spacing: -0.055em;
|
|
307
|
+
}
|
|
308
|
+
.lede {
|
|
309
|
+
margin: 16px 0 0;
|
|
310
|
+
max-width: 58ch;
|
|
311
|
+
color: var(--muted);
|
|
312
|
+
font-size: clamp(0.96rem, 2vw, 1.08rem);
|
|
313
|
+
line-height: 1.6;
|
|
314
|
+
}
|
|
315
|
+
.capabilities {
|
|
316
|
+
display: grid;
|
|
317
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
318
|
+
gap: 10px;
|
|
319
|
+
margin: 26px 0 0;
|
|
320
|
+
padding: 0;
|
|
321
|
+
list-style: none;
|
|
322
|
+
}
|
|
323
|
+
.capabilities li {
|
|
324
|
+
min-height: 84px;
|
|
325
|
+
padding: 15px;
|
|
326
|
+
border: 1px solid var(--border);
|
|
327
|
+
border-radius: 15px;
|
|
328
|
+
background: var(--surface-raised);
|
|
329
|
+
}
|
|
330
|
+
.capabilities strong { display: block; margin-block-end: 4px; font-size: 0.94rem; }
|
|
331
|
+
.capabilities span { display: block; color: var(--muted); font-size: 0.81rem; line-height: 1.45; }
|
|
332
|
+
.actions { display: grid; grid-template-columns: 1.45fr 1fr; gap: 10px; margin-block-start: 26px; }
|
|
333
|
+
.button {
|
|
334
|
+
display: inline-flex;
|
|
335
|
+
min-height: 48px;
|
|
336
|
+
align-items: center;
|
|
337
|
+
justify-content: center;
|
|
338
|
+
padding: 12px 18px;
|
|
339
|
+
border: 1px solid var(--border);
|
|
340
|
+
border-radius: 13px;
|
|
341
|
+
background: transparent;
|
|
342
|
+
color: #f4f4f5;
|
|
343
|
+
font: inherit;
|
|
344
|
+
font-weight: 750;
|
|
345
|
+
text-align: center;
|
|
346
|
+
text-decoration: none;
|
|
347
|
+
cursor: pointer;
|
|
348
|
+
transition: transform 140ms ease, background 140ms ease, border-color 140ms ease;
|
|
349
|
+
}
|
|
350
|
+
.button:hover { transform: translateY(-1px); border-color: #454b57; background: #1b1e24; }
|
|
351
|
+
.button:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
|
|
352
|
+
.button-primary { border-color: var(--primary); background: var(--primary); color: var(--primary-ink); }
|
|
353
|
+
.button-primary:hover { border-color: #fff; background: #fff; }
|
|
354
|
+
.helper { margin: 12px 0 0; color: #777f8d; font-size: 0.78rem; line-height: 1.45; }
|
|
355
|
+
.next-step {
|
|
356
|
+
margin-block-start: 24px;
|
|
357
|
+
padding: 18px;
|
|
358
|
+
border: 1px solid rgb(114 242 173 / 28%);
|
|
359
|
+
border-radius: 16px;
|
|
360
|
+
background: rgb(114 242 173 / 7%);
|
|
361
|
+
}
|
|
362
|
+
.next-step h2 { margin: 0; font-size: clamp(1.3rem, 4vw, 1.65rem); letter-spacing: -0.03em; }
|
|
363
|
+
.next-step p { margin: 8px 0 0; color: var(--muted); line-height: 1.55; }
|
|
364
|
+
.next-step .actions { margin-block-start: 18px; }
|
|
365
|
+
@media (max-width: 560px) {
|
|
366
|
+
body { place-items: start center; }
|
|
367
|
+
main { border-radius: 20px; }
|
|
368
|
+
.capabilities, .actions { grid-template-columns: 1fr; }
|
|
369
|
+
.capabilities li { min-height: auto; }
|
|
370
|
+
}
|
|
371
|
+
</style>
|
|
372
|
+
</head>
|
|
373
|
+
<body>
|
|
374
|
+
<main>
|
|
375
|
+
<div class="brand"><span class="brand-mark">N</span> Notis</div>
|
|
376
|
+
<section id="connected-view" aria-labelledby="connected-title">
|
|
377
|
+
<div class="status"><span class="status-dot"></span> CLI ready</div>
|
|
378
|
+
<h1 id="connected-title">Notis CLI is connected.</h1>
|
|
379
|
+
<p class="lede">
|
|
380
|
+
Your terminal is ready. Add the Desktop app to unlock everything that runs safely on this computer.
|
|
381
|
+
</p>
|
|
382
|
+
<ul class="capabilities" aria-label="Desktop capabilities">
|
|
383
|
+
<li><strong>Sync agent skills</strong><span>Keep your Notis skills available in Codex, Claude Code, Cursor, and other local agents.</span></li>
|
|
384
|
+
<li><strong>Control your computer</strong><span>Let approved agents use local apps, files, and computer controls when you ask.</span></li>
|
|
385
|
+
<li><strong>Connect local MCP</strong><span>Use local MCP servers and tools without exposing them to the public internet.</span></li>
|
|
386
|
+
<li><strong>Approve local actions</strong><span>Review sensitive computer actions from the Desktop app before they run.</span></li>
|
|
387
|
+
</ul>
|
|
388
|
+
<div class="actions">
|
|
389
|
+
<button class="button button-primary" id="download-desktop" type="button">Download Notis Desktop</button>
|
|
390
|
+
<a class="button" href="${escapeHtml(webAppUrl)}">Open Web App</a>
|
|
391
|
+
</div>
|
|
392
|
+
<p class="helper">The CLI stays connected even if you continue in the web app.</p>
|
|
393
|
+
</section>
|
|
394
|
+
<section class="next-step" id="desktop-ready-view" aria-labelledby="desktop-ready-title" hidden>
|
|
395
|
+
<div class="status"><span class="status-dot"></span> Download started</div>
|
|
396
|
+
<h2 id="desktop-ready-title" tabindex="-1">Quick login to Notis Desktop</h2>
|
|
397
|
+
<p>
|
|
398
|
+
Install the app, then use quick login. Your signed-in browser securely hands the current session to Desktop.
|
|
399
|
+
</p>
|
|
400
|
+
<div class="actions">
|
|
401
|
+
<a class="button button-primary" href="${escapeHtml(quickLoginUrl.toString())}">Open & sign in to Desktop</a>
|
|
402
|
+
<a class="button" href="${escapeHtml(webAppUrl)}">Open Web App</a>
|
|
403
|
+
</div>
|
|
404
|
+
<button class="button" id="download-again" type="button" style="width:100%;margin-top:10px">Download again</button>
|
|
405
|
+
</section>
|
|
406
|
+
</main>
|
|
407
|
+
<script>
|
|
408
|
+
(() => {
|
|
409
|
+
const downloads = ${serializedDownloads};
|
|
410
|
+
const downloadButton = document.getElementById('download-desktop');
|
|
411
|
+
const downloadAgainButton = document.getElementById('download-again');
|
|
412
|
+
const connectedView = document.getElementById('connected-view');
|
|
413
|
+
const readyView = document.getElementById('desktop-ready-view');
|
|
414
|
+
|
|
415
|
+
const resolveDownload = async () => {
|
|
416
|
+
const ua = navigator.userAgent || '';
|
|
417
|
+
if (/Windows/i.test(ua)) return downloads.base + '/win32/x64/notis-x64.exe';
|
|
418
|
+
if (/Linux/i.test(ua) && !/Android/i.test(ua)) return downloads.base + '/linux/x64/notis-linux-x64.zip';
|
|
419
|
+
if (/Macintosh|Mac OS X/i.test(ua)) {
|
|
420
|
+
let architecture = '';
|
|
421
|
+
try {
|
|
422
|
+
architecture = (await navigator.userAgentData?.getHighEntropyValues?.(['architecture']))?.architecture || '';
|
|
423
|
+
} catch {}
|
|
424
|
+
const path = /arm/i.test(architecture)
|
|
425
|
+
? '/darwin/arm64/notis-arm64.dmg'
|
|
426
|
+
: '/darwin/x64/notis-x64.dmg';
|
|
427
|
+
return downloads.base + path;
|
|
428
|
+
}
|
|
429
|
+
return downloads.fallback;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const startDownload = async () => {
|
|
433
|
+
const downloadUrl = await resolveDownload();
|
|
434
|
+
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
|
|
435
|
+
connectedView.hidden = true;
|
|
436
|
+
readyView.hidden = false;
|
|
437
|
+
document.title = 'Quick login to Notis Desktop';
|
|
438
|
+
document.getElementById('desktop-ready-title')?.focus?.();
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
downloadButton?.addEventListener('click', startDownload);
|
|
442
|
+
downloadAgainButton?.addEventListener('click', async () => {
|
|
443
|
+
const downloadUrl = await resolveDownload();
|
|
444
|
+
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
|
|
445
|
+
});
|
|
446
|
+
})();
|
|
447
|
+
</script>
|
|
448
|
+
</body>
|
|
449
|
+
</html>`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export async function createLoopbackReceiver({
|
|
453
|
+
state,
|
|
454
|
+
timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
|
|
455
|
+
portalOrigin,
|
|
456
|
+
} = {}) {
|
|
457
|
+
let consumed = false;
|
|
458
|
+
let resolveCode;
|
|
459
|
+
let rejectCode;
|
|
460
|
+
let timeout;
|
|
461
|
+
let pendingResponse = null;
|
|
462
|
+
const result = new Promise((resolve, reject) => {
|
|
463
|
+
resolveCode = resolve;
|
|
464
|
+
rejectCode = reject;
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const server = createServer((request, response) => {
|
|
468
|
+
const address = server.address();
|
|
469
|
+
const expectedHost = address && typeof address === 'object'
|
|
470
|
+
? `127.0.0.1:${address.port}`
|
|
471
|
+
: '';
|
|
472
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
473
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
474
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
475
|
+
response.setHeader('X-Frame-Options', 'DENY');
|
|
476
|
+
|
|
477
|
+
if (request.headers.host !== expectedHost) {
|
|
478
|
+
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
479
|
+
response.end(callbackHtml('Invalid callback', 'The callback host was not accepted.'));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const url = new URL(request.url || '/', `http://${expectedHost}`);
|
|
483
|
+
if (request.method !== 'GET' || url.pathname !== '/callback') {
|
|
484
|
+
response.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
485
|
+
response.end(callbackHtml('Not found', 'This callback path does not exist.'));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (consumed) {
|
|
489
|
+
response.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
490
|
+
response.end(callbackHtml('Already used', 'This authorization callback was already handled.'));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
consumed = true;
|
|
494
|
+
|
|
495
|
+
const returnedState = url.searchParams.get('state') || '';
|
|
496
|
+
const code = url.searchParams.get('code');
|
|
497
|
+
const error = url.searchParams.get('error');
|
|
498
|
+
if (!stateMatches(state, returnedState)) {
|
|
499
|
+
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
500
|
+
response.end(callbackHtml('Authorization failed', 'The callback state did not match.'));
|
|
501
|
+
rejectCode(oauthError('oauth_state_mismatch', 'The OAuth callback state did not match.'));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (error || !code) {
|
|
505
|
+
const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
|
|
506
|
+
response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
507
|
+
response.end(callbackHtml('Authorization not completed', description));
|
|
508
|
+
rejectCode(oauthError(error || 'oauth_code_missing', description));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Keep the browser request pending until the CLI has exchanged and
|
|
512
|
+
// persisted the code. Receiving a valid callback is not enough to claim
|
|
513
|
+
// that the command line is connected.
|
|
514
|
+
pendingResponse = response;
|
|
515
|
+
resolveCode(code);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await new Promise((resolve, reject) => {
|
|
519
|
+
server.once('error', reject);
|
|
520
|
+
server.listen(0, '127.0.0.1', () => {
|
|
521
|
+
server.off('error', reject);
|
|
522
|
+
resolve();
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
const address = server.address();
|
|
526
|
+
const port = address && typeof address === 'object' ? address.port : null;
|
|
527
|
+
if (!port) {
|
|
528
|
+
server.close();
|
|
529
|
+
throw oauthError('oauth_loopback_failed', 'Could not start the local OAuth callback listener.');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
timeout = setTimeout(() => {
|
|
533
|
+
rejectCode(oauthError('oauth_timeout', `Authorization timed out after ${timeoutMs}ms.`));
|
|
534
|
+
}, timeoutMs);
|
|
535
|
+
|
|
536
|
+
const close = async () => {
|
|
537
|
+
clearTimeout(timeout);
|
|
538
|
+
if (pendingResponse && !pendingResponse.writableEnded) {
|
|
539
|
+
pendingResponse.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
540
|
+
pendingResponse.end(callbackHtml(
|
|
541
|
+
'Authorization did not finish',
|
|
542
|
+
'Return to the terminal for details, then retry sign in.',
|
|
543
|
+
));
|
|
544
|
+
pendingResponse = null;
|
|
545
|
+
}
|
|
546
|
+
if (!server.listening) return;
|
|
547
|
+
await new Promise((resolve) => server.close(resolve));
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
port,
|
|
552
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
553
|
+
waitForCode: () => result,
|
|
554
|
+
complete: () => {
|
|
555
|
+
if (!pendingResponse || pendingResponse.writableEnded) return;
|
|
556
|
+
const connectedUrl = new URL(
|
|
557
|
+
'/cli-connected',
|
|
558
|
+
`${normalizePortalOrigin(portalOrigin)}/`,
|
|
559
|
+
);
|
|
560
|
+
pendingResponse.writeHead(302, {
|
|
561
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
562
|
+
Location: connectedUrl.toString(),
|
|
563
|
+
});
|
|
564
|
+
pendingResponse.end(connectedCallbackHtml({ portalOrigin }));
|
|
565
|
+
pendingResponse = null;
|
|
566
|
+
},
|
|
567
|
+
fail: () => {
|
|
568
|
+
if (!pendingResponse || pendingResponse.writableEnded) return;
|
|
569
|
+
pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
570
|
+
pendingResponse.end(callbackHtml(
|
|
571
|
+
'Authorization failed',
|
|
572
|
+
'The CLI could not finish signing in. Return to the terminal for details.',
|
|
573
|
+
));
|
|
574
|
+
pendingResponse = null;
|
|
575
|
+
},
|
|
576
|
+
close,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function browserOpenCommand(url, platform = process.platform) {
|
|
581
|
+
const command = platform === 'darwin'
|
|
582
|
+
? 'open'
|
|
583
|
+
: platform === 'win32'
|
|
584
|
+
? 'rundll32.exe'
|
|
585
|
+
: 'xdg-open';
|
|
586
|
+
const args = platform === 'win32'
|
|
587
|
+
? ['url.dll,FileProtocolHandler', url]
|
|
588
|
+
: [url];
|
|
589
|
+
return { command, args };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function openBrowser(url) {
|
|
593
|
+
const { command, args } = browserOpenCommand(url);
|
|
594
|
+
try {
|
|
595
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
596
|
+
// A missing opener emits an async 'error' event that no try/catch can reach,
|
|
597
|
+
// and an unhandled one would take the whole login down.
|
|
598
|
+
child.on('error', () => {});
|
|
599
|
+
child.unref();
|
|
600
|
+
} catch {
|
|
601
|
+
// Non-fatal. The authorize URL is printed alongside this call.
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function storedOAuthMetadata(runtime, profile) {
|
|
606
|
+
const issuer = String(profile.oauth_issuer || '').replace(/\/+$/, '');
|
|
607
|
+
const apiBase = getOAuthApiBase(profile)
|
|
608
|
+
|| String(profile.api_base || runtime.apiBase || '').replace(/\/+$/, '');
|
|
609
|
+
const resource = getOAuthResource(profile) || (apiBase ? `${apiBase}/cli` : '');
|
|
610
|
+
if (!issuer || !apiBase || !resource || !profile.oauth_client_id) {
|
|
611
|
+
throw oauthError(
|
|
612
|
+
'oauth_metadata_missing',
|
|
613
|
+
'The stored OAuth profile is incomplete. Run notis login again.',
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
apiBase,
|
|
618
|
+
issuer,
|
|
619
|
+
resource,
|
|
620
|
+
clientId: profile.oauth_client_id,
|
|
621
|
+
tokenEndpoint: `${issuer}/oauth/token`,
|
|
622
|
+
revocationEndpoint: `${issuer}/oauth/revoke`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function buildAuthorizeUrl(metadata, {
|
|
627
|
+
redirectUri,
|
|
628
|
+
challenge,
|
|
629
|
+
state,
|
|
630
|
+
scopes,
|
|
631
|
+
}) {
|
|
632
|
+
const url = new URL(metadata.authorizationEndpoint);
|
|
633
|
+
url.searchParams.set('response_type', 'code');
|
|
634
|
+
url.searchParams.set('client_id', metadata.clientId);
|
|
635
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
636
|
+
url.searchParams.set('resource', metadata.resource);
|
|
637
|
+
url.searchParams.set('scope', scopes.join(' '));
|
|
638
|
+
url.searchParams.set('code_challenge', challenge);
|
|
639
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
640
|
+
url.searchParams.set('state', state);
|
|
641
|
+
return url.toString();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function exchangeCode(metadata, {
|
|
645
|
+
code,
|
|
646
|
+
redirectUri,
|
|
647
|
+
verifier,
|
|
648
|
+
}, fetchImpl = fetch) {
|
|
649
|
+
const body = new URLSearchParams({
|
|
650
|
+
grant_type: 'authorization_code',
|
|
651
|
+
code,
|
|
652
|
+
client_id: metadata.clientId,
|
|
653
|
+
redirect_uri: redirectUri,
|
|
654
|
+
resource: metadata.resource,
|
|
655
|
+
code_verifier: verifier,
|
|
656
|
+
});
|
|
657
|
+
return fetchJson(
|
|
658
|
+
metadata.tokenEndpoint,
|
|
659
|
+
{
|
|
660
|
+
method: 'POST',
|
|
661
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
662
|
+
body,
|
|
663
|
+
},
|
|
664
|
+
fetchImpl,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
|
|
669
|
+
const now = Math.floor(Date.now() / 1000);
|
|
670
|
+
const payload = decodeJwtPayload(tokenResponse.access_token);
|
|
671
|
+
const config = updateConfig((latest) => {
|
|
672
|
+
const next = ensureProfile(latest, runtime.profileName);
|
|
673
|
+
const profile = next.profiles[runtime.profileName];
|
|
674
|
+
next.profiles[runtime.profileName] = {
|
|
675
|
+
...profile,
|
|
676
|
+
oauth_api_base: metadata.apiBase || runtime.apiBase,
|
|
677
|
+
oauth_resource: metadata.resource,
|
|
678
|
+
oauth_access_token: tokenResponse.access_token,
|
|
679
|
+
oauth_refresh_token: tokenResponse.refresh_token || profile.oauth_refresh_token,
|
|
680
|
+
oauth_access_expires_at: now + Number(tokenResponse.expires_in || 0),
|
|
681
|
+
oauth_refresh_expires_at:
|
|
682
|
+
now + Number(tokenResponse.refresh_expires_in || DEFAULT_REFRESH_EXPIRES_IN),
|
|
683
|
+
oauth_client_id: metadata.clientId,
|
|
684
|
+
oauth_issuer: metadata.issuer,
|
|
685
|
+
oauth_scopes: String(tokenResponse.scope || '').split(/\s+/).filter(Boolean),
|
|
686
|
+
oauth_user_id: payload.sub || payload.notis_user_id,
|
|
687
|
+
};
|
|
688
|
+
return next;
|
|
689
|
+
}, runtime.worktreeRuntime);
|
|
690
|
+
return config.profiles[runtime.profileName];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function pendingAuthorizationFile(runtime) {
|
|
694
|
+
const profileKey = createHash('sha256')
|
|
695
|
+
.update(String(runtime.profileName || 'default'))
|
|
696
|
+
.digest('hex')
|
|
697
|
+
.slice(0, 16);
|
|
698
|
+
return `${resolveConfigFile(runtime)}.pending-login.${profileKey}`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function legacyPendingAuthorizationFile(runtime) {
|
|
702
|
+
return `${resolveConfigFile(runtime)}.pending-login`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// The PKCE verifier outlives the process that created it whenever the browser
|
|
706
|
+
// hand-off cannot block on a terminal, so it is parked next to the config
|
|
707
|
+
// rather than in it: normalizeConfig drops unknown profile keys, and a
|
|
708
|
+
// half-finished login must never survive as profile state.
|
|
709
|
+
function savePendingAuthorization(runtime, pending) {
|
|
710
|
+
const file = pendingAuthorizationFile(runtime);
|
|
711
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
712
|
+
writeFileSync(file, JSON.stringify(pending, null, 2), { mode: 0o600 });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function readPendingAuthorization(runtime) {
|
|
716
|
+
for (const file of [
|
|
717
|
+
pendingAuthorizationFile(runtime),
|
|
718
|
+
legacyPendingAuthorizationFile(runtime),
|
|
719
|
+
]) {
|
|
720
|
+
let pending;
|
|
721
|
+
try {
|
|
722
|
+
pending = JSON.parse(readFileSync(file, 'utf-8'));
|
|
723
|
+
} catch {
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (!pending?.verifier || !pending?.redirect_uri) continue;
|
|
727
|
+
if (Number(pending.expires_at) <= Math.floor(Date.now() / 1000)) continue;
|
|
728
|
+
if (pending.profile !== runtime.profileName) continue;
|
|
729
|
+
return { ...pending, pending_file: file };
|
|
730
|
+
}
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runtime)) {
|
|
735
|
+
try {
|
|
736
|
+
rmSync(file);
|
|
737
|
+
} catch {
|
|
738
|
+
// Nothing to clear.
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function quoteShellArgument(value) {
|
|
743
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function redeemCommand(profileName) {
|
|
747
|
+
return [
|
|
748
|
+
'npx --package @notis_ai/cli@latest -- notis',
|
|
749
|
+
`--profile ${quoteShellArgument(profileName || 'default')}`,
|
|
750
|
+
'login --code <code>',
|
|
751
|
+
].join(' ');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function updateRuntimeFromOAuthProfile(runtime, profile) {
|
|
755
|
+
const oauthApiBase = getOAuthApiBase(profile);
|
|
756
|
+
runtime.jwt = profile.oauth_access_token;
|
|
757
|
+
runtime.credentialKind = 'oauth';
|
|
758
|
+
runtime.credentialSource = 'oauth';
|
|
759
|
+
runtime.oauthAccessToken = profile.oauth_access_token;
|
|
760
|
+
runtime.oauthRefreshToken = profile.oauth_refresh_token;
|
|
761
|
+
runtime.oauthAccessExpiresAt = profile.oauth_access_expires_at;
|
|
762
|
+
runtime.oauthRefreshExpiresAt = profile.oauth_refresh_expires_at;
|
|
763
|
+
runtime.oauthClientId = profile.oauth_client_id;
|
|
764
|
+
runtime.oauthIssuer = profile.oauth_issuer;
|
|
765
|
+
runtime.oauthApiBase = oauthApiBase;
|
|
766
|
+
runtime.oauthResource = getOAuthResource(profile);
|
|
767
|
+
runtime.oauthScopes = profile.oauth_scopes || [];
|
|
768
|
+
runtime.oauthUserId = profile.oauth_user_id;
|
|
769
|
+
if (oauthApiBase && !runtime.requestedApiBase) {
|
|
770
|
+
runtime.apiBase = oauthApiBase;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function assertOAuthApiTarget(runtime, profile) {
|
|
775
|
+
const requestedApiBase = String(runtime.requestedApiBase || '').replace(/\/+$/, '');
|
|
776
|
+
const oauthApiBase = getOAuthApiBase(profile);
|
|
777
|
+
if (requestedApiBase && oauthApiBase && requestedApiBase !== oauthApiBase) {
|
|
778
|
+
throw oauthError(
|
|
779
|
+
'oauth_api_target_mismatch',
|
|
780
|
+
`This OAuth grant belongs to ${oauthApiBase}, not ${requestedApiBase}. Run login for the requested environment.`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export async function ensureFreshOAuthCredential(runtime, fetchImpl = fetch) {
|
|
786
|
+
if (runtime.credentialKind !== 'oauth') {
|
|
787
|
+
return Boolean(runtime.jwt);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
|
|
791
|
+
assertOAuthApiTarget(runtime, profile);
|
|
792
|
+
if (!credentialIsExpired({ credentialKind: 'oauth' }, profile)) {
|
|
793
|
+
updateRuntimeFromOAuthProfile(runtime, profile);
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return refreshOAuthCredential(runtime, fetchImpl);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function readPastedCode() {
|
|
801
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
802
|
+
try {
|
|
803
|
+
const value = (await prompt.question('Paste the authorization code: ')).trim();
|
|
804
|
+
if (!value) throw oauthError('oauth_code_missing', 'No authorization code was provided.');
|
|
805
|
+
try {
|
|
806
|
+
const parsed = new URL(value);
|
|
807
|
+
return parsed.searchParams.get('code') || value;
|
|
808
|
+
} catch {
|
|
809
|
+
return value;
|
|
810
|
+
}
|
|
811
|
+
} finally {
|
|
812
|
+
prompt.close();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function redeemAuthorizationCode(runtime, code, fetchImpl) {
|
|
817
|
+
if (!code) {
|
|
818
|
+
throw oauthError('oauth_code_missing', 'No authorization code was provided.');
|
|
819
|
+
}
|
|
820
|
+
const pending = readPendingAuthorization(runtime);
|
|
821
|
+
if (!pending) {
|
|
822
|
+
throw oauthError(
|
|
823
|
+
'oauth_pending_login_missing',
|
|
824
|
+
'No pending authorization for this profile. Run notis login again to start one.',
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
// The grant belongs to the environment that issued it. Redeeming without
|
|
828
|
+
// repeating --api-base must not persist a token under a different backend.
|
|
829
|
+
if (pending.api_base) {
|
|
830
|
+
runtime.apiBase = pending.api_base;
|
|
831
|
+
}
|
|
832
|
+
const metadata = {
|
|
833
|
+
issuer: pending.issuer,
|
|
834
|
+
resource: pending.resource,
|
|
835
|
+
clientId: pending.client_id,
|
|
836
|
+
tokenEndpoint: pending.token_endpoint,
|
|
837
|
+
};
|
|
838
|
+
if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
|
|
839
|
+
throw oauthError(
|
|
840
|
+
'oauth_pending_login_invalid',
|
|
841
|
+
'The pending authorization is incomplete. Run notis login again.',
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
const tokenResponse = await exchangeCode(
|
|
845
|
+
metadata,
|
|
846
|
+
{ code, redirectUri: pending.redirect_uri, verifier: pending.verifier },
|
|
847
|
+
fetchImpl,
|
|
848
|
+
);
|
|
849
|
+
clearPendingAuthorization(runtime, pending.pending_file);
|
|
850
|
+
const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
|
|
851
|
+
updateRuntimeFromOAuthProfile(runtime, profile);
|
|
852
|
+
return { profile, metadata };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export async function loginWithOAuth(runtime, options = {}, output, fetchImpl = fetch) {
|
|
856
|
+
if (options.code) {
|
|
857
|
+
return redeemAuthorizationCode(runtime, String(options.code).trim(), fetchImpl);
|
|
858
|
+
}
|
|
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
|
+
|
|
867
|
+
const metadata = await discoverCliOAuth(runtime.apiBase, fetchImpl);
|
|
868
|
+
const { verifier, challenge } = createPkce();
|
|
869
|
+
const state = base64url(randomBytes(32));
|
|
870
|
+
const scopes = options.scope?.length
|
|
871
|
+
? [...new Set(options.scope)]
|
|
872
|
+
: DEFAULT_CLI_OAUTH_SCOPES;
|
|
873
|
+
const timeoutMs = Number(options.timeoutSeconds) > 0
|
|
874
|
+
? Number(options.timeoutSeconds) * 1000
|
|
875
|
+
: Number(options.timeoutMs) > 0
|
|
876
|
+
? Number(options.timeoutMs)
|
|
877
|
+
: DEFAULT_LOGIN_TIMEOUT_MS;
|
|
878
|
+
const nonBlockingAgent = Boolean(
|
|
879
|
+
runtime.agentMode || runtime.outputMode === 'json',
|
|
880
|
+
);
|
|
881
|
+
const pasteCode = Boolean(options.pasteCode || nonBlockingAgent);
|
|
882
|
+
let receiver;
|
|
883
|
+
let redirectUri;
|
|
884
|
+
|
|
885
|
+
if (pasteCode) {
|
|
886
|
+
redirectUri = metadata.copyPasteRedirectUri;
|
|
887
|
+
if (!redirectUri) {
|
|
888
|
+
throw oauthError('oauth_metadata_invalid', 'Notis did not advertise a copy-paste callback.');
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
let portalOrigin = 'https://app.notis.ai';
|
|
892
|
+
try {
|
|
893
|
+
portalOrigin = new URL(metadata.copyPasteRedirectUri).origin;
|
|
894
|
+
} catch {
|
|
895
|
+
// The OAuth server owns this metadata. Keep the public Portal fallback if
|
|
896
|
+
// a development server omits or returns an invalid copy-paste URL.
|
|
897
|
+
}
|
|
898
|
+
receiver = await createLoopbackReceiver({ state, timeoutMs, portalOrigin });
|
|
899
|
+
redirectUri = receiver.redirectUri;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const authorizeUrl = buildAuthorizeUrl(metadata, {
|
|
903
|
+
redirectUri,
|
|
904
|
+
challenge,
|
|
905
|
+
state,
|
|
906
|
+
scopes,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// Every copy-paste hand-off leaves the browser holding a code this process
|
|
910
|
+
// may no longer be around to receive, so park the verifier for `--code`.
|
|
911
|
+
// The window is the user's, not the terminal's: signing up and consenting
|
|
912
|
+
// routinely outlasts the in-process wait.
|
|
913
|
+
if (pasteCode) {
|
|
914
|
+
savePendingAuthorization(runtime, {
|
|
915
|
+
profile: runtime.profileName,
|
|
916
|
+
api_base: runtime.apiBase,
|
|
917
|
+
verifier,
|
|
918
|
+
state,
|
|
919
|
+
redirect_uri: redirectUri,
|
|
920
|
+
issuer: metadata.issuer,
|
|
921
|
+
resource: metadata.resource,
|
|
922
|
+
client_id: metadata.clientId,
|
|
923
|
+
token_endpoint: metadata.tokenEndpoint,
|
|
924
|
+
expires_at: Math.floor(Date.now() / 1000) + PENDING_LOGIN_TTL_SECONDS,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (nonBlockingAgent) {
|
|
929
|
+
await receiver?.close();
|
|
930
|
+
return {
|
|
931
|
+
agentAuthorization: {
|
|
932
|
+
authorize_url: authorizeUrl,
|
|
933
|
+
expires_in: PENDING_LOGIN_TTL_SECONDS,
|
|
934
|
+
redeem_command: redeemCommand(runtime.profileName),
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Always surface the URL: opening the browser can fail silently, and the
|
|
940
|
+
// wait below is useless without something the user can paste themselves.
|
|
941
|
+
output?.note?.(`Authorize Notis CLI: ${authorizeUrl}`);
|
|
942
|
+
if (options.browser !== false && !pasteCode) {
|
|
943
|
+
openBrowser(authorizeUrl);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
const code = pasteCode ? await readPastedCode() : await receiver.waitForCode();
|
|
948
|
+
const tokenResponse = await exchangeCode(
|
|
949
|
+
metadata,
|
|
950
|
+
{ code, redirectUri, verifier },
|
|
951
|
+
fetchImpl,
|
|
952
|
+
);
|
|
953
|
+
const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
|
|
954
|
+
updateRuntimeFromOAuthProfile(runtime, profile);
|
|
955
|
+
receiver?.complete();
|
|
956
|
+
return { profile, metadata };
|
|
957
|
+
} catch (error) {
|
|
958
|
+
receiver?.fail();
|
|
959
|
+
throw error;
|
|
960
|
+
} finally {
|
|
961
|
+
await receiver?.close();
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function lockIsStale() {
|
|
966
|
+
try {
|
|
967
|
+
return Date.now() - statSync(OAUTH_LOCK_DIR).mtimeMs > 45_000;
|
|
968
|
+
} catch {
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function acquireRefreshLock(runtime, waitMs = 60_000) {
|
|
974
|
+
mkdirSync(join(homedir(), '.notis'), { recursive: true });
|
|
975
|
+
const deadline = Date.now() + waitMs;
|
|
976
|
+
for (;;) {
|
|
977
|
+
try {
|
|
978
|
+
mkdirSync(OAUTH_LOCK_DIR);
|
|
979
|
+
return true;
|
|
980
|
+
} catch (error) {
|
|
981
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
982
|
+
const profile = getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName);
|
|
983
|
+
if (
|
|
984
|
+
profile.oauth_access_token
|
|
985
|
+
&& profile.oauth_access_token !== runtime.oauthAccessToken
|
|
986
|
+
&& !credentialIsExpired({ credentialKind: 'oauth' }, profile)
|
|
987
|
+
) {
|
|
988
|
+
updateRuntimeFromOAuthProfile(runtime, profile);
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
if (lockIsStale()) {
|
|
992
|
+
try {
|
|
993
|
+
rmdirSync(OAUTH_LOCK_DIR);
|
|
994
|
+
continue;
|
|
995
|
+
} catch {
|
|
996
|
+
// The owner may have completed between stat and removal.
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (Date.now() >= deadline) {
|
|
1000
|
+
throw oauthError('oauth_refresh_lock_timeout', 'Timed out waiting for another CLI process to refresh OAuth.');
|
|
1001
|
+
}
|
|
1002
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
|
|
1008
|
+
const ownsLock = await acquireRefreshLock(runtime);
|
|
1009
|
+
if (!ownsLock) return true;
|
|
1010
|
+
try {
|
|
1011
|
+
const config = loadConfig(runtime.worktreeRuntime);
|
|
1012
|
+
const profile = getProfile(config, runtime.profileName);
|
|
1013
|
+
assertOAuthApiTarget(runtime, profile);
|
|
1014
|
+
if (
|
|
1015
|
+
profile.oauth_access_token
|
|
1016
|
+
&& profile.oauth_access_token !== runtime.oauthAccessToken
|
|
1017
|
+
&& !credentialIsExpired({ credentialKind: 'oauth' }, profile)
|
|
1018
|
+
) {
|
|
1019
|
+
updateRuntimeFromOAuthProfile(runtime, profile);
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
if (!profile.oauth_refresh_token || !profile.oauth_client_id || !profile.oauth_issuer) {
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const metadata = storedOAuthMetadata(runtime, profile);
|
|
1027
|
+
const response = await fetchJson(
|
|
1028
|
+
metadata.tokenEndpoint,
|
|
1029
|
+
{
|
|
1030
|
+
method: 'POST',
|
|
1031
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1032
|
+
body: new URLSearchParams({
|
|
1033
|
+
grant_type: 'refresh_token',
|
|
1034
|
+
refresh_token: profile.oauth_refresh_token,
|
|
1035
|
+
client_id: profile.oauth_client_id,
|
|
1036
|
+
resource: metadata.resource,
|
|
1037
|
+
}),
|
|
1038
|
+
},
|
|
1039
|
+
fetchImpl,
|
|
1040
|
+
);
|
|
1041
|
+
const updated = persistOAuthTokenResponse(runtime, metadata, response);
|
|
1042
|
+
updateRuntimeFromOAuthProfile(runtime, updated);
|
|
1043
|
+
return true;
|
|
1044
|
+
} finally {
|
|
1045
|
+
try {
|
|
1046
|
+
rmdirSync(OAUTH_LOCK_DIR);
|
|
1047
|
+
} catch {
|
|
1048
|
+
// A process exit or external cleanup may already have removed the lock.
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchImpl = fetch) {
|
|
1054
|
+
const config = loadConfig(runtime.worktreeRuntime);
|
|
1055
|
+
const profileNames = allProfiles
|
|
1056
|
+
? Object.keys(config.profiles)
|
|
1057
|
+
: [runtime.profileName];
|
|
1058
|
+
for (const profileName of profileNames) {
|
|
1059
|
+
const profile = config.profiles[profileName] || {};
|
|
1060
|
+
if (
|
|
1061
|
+
profile.oauth_refresh_token
|
|
1062
|
+
&& profile.oauth_client_id
|
|
1063
|
+
&& profile.oauth_issuer
|
|
1064
|
+
) {
|
|
1065
|
+
try {
|
|
1066
|
+
const metadata = storedOAuthMetadata(runtime, profile);
|
|
1067
|
+
await fetchJson(
|
|
1068
|
+
metadata.revocationEndpoint,
|
|
1069
|
+
{
|
|
1070
|
+
method: 'POST',
|
|
1071
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1072
|
+
body: new URLSearchParams({
|
|
1073
|
+
token: profile.oauth_refresh_token,
|
|
1074
|
+
client_id: profile.oauth_client_id,
|
|
1075
|
+
}),
|
|
1076
|
+
},
|
|
1077
|
+
fetchImpl,
|
|
1078
|
+
);
|
|
1079
|
+
} catch {
|
|
1080
|
+
// Local credential removal still succeeds when the remote grant is
|
|
1081
|
+
// already gone or the network is unavailable.
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
updateConfig((latest) => {
|
|
1086
|
+
for (const profileName of profileNames) {
|
|
1087
|
+
const profile = latest.profiles[profileName] || {};
|
|
1088
|
+
latest.profiles[profileName] = {
|
|
1089
|
+
...profile,
|
|
1090
|
+
oauth_access_token: undefined,
|
|
1091
|
+
oauth_refresh_token: undefined,
|
|
1092
|
+
oauth_access_expires_at: undefined,
|
|
1093
|
+
oauth_refresh_expires_at: undefined,
|
|
1094
|
+
oauth_client_id: undefined,
|
|
1095
|
+
oauth_issuer: undefined,
|
|
1096
|
+
oauth_api_base: undefined,
|
|
1097
|
+
oauth_resource: undefined,
|
|
1098
|
+
oauth_scopes: undefined,
|
|
1099
|
+
oauth_user_id: undefined,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
return latest;
|
|
1103
|
+
}, runtime.worktreeRuntime);
|
|
1104
|
+
return { profiles: profileNames };
|
|
1105
|
+
}
|