@rootly/wizard 0.1.0
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/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/rootly-logo-glyph-purple.png +0 -0
- package/assets/rootly-logo-glyph.png +0 -0
- package/assets/welcome-screen.png +0 -0
- package/package.json +47 -0
- package/src/actions/guided.js +87 -0
- package/src/actions/inspect.js +341 -0
- package/src/actions/integrations.js +75 -0
- package/src/actions/mcp.js +52 -0
- package/src/actions/oneshot.js +243 -0
- package/src/actions/phone.js +122 -0
- package/src/actions/registry.js +373 -0
- package/src/actions/setup.js +574 -0
- package/src/actions/testing.js +141 -0
- package/src/actions/workflow.js +47 -0
- package/src/auth.js +553 -0
- package/src/cli.js +199 -0
- package/src/detect-state.js +232 -0
- package/src/format.js +43 -0
- package/src/mcp.js +254 -0
- package/src/rootly-api.js +325 -0
- package/src/runtime.js +55 -0
- package/src/tui/components/AppShell.js +68 -0
- package/src/tui/components/Banner.js +145 -0
- package/src/tui/components/BigText.js +46 -0
- package/src/tui/components/Celebration.js +47 -0
- package/src/tui/components/KeyValueList.js +22 -0
- package/src/tui/components/MenuList.js +170 -0
- package/src/tui/components/MultiSelectList.js +181 -0
- package/src/tui/components/NoticeBox.js +56 -0
- package/src/tui/components/SlideReveal.js +52 -0
- package/src/tui/index.js +2078 -0
- package/src/tui/screens/ListScreen.js +29 -0
- package/src/tui/screens/LoadFailedScreen.js +30 -0
- package/src/tui/screens/LoadingScreen.js +32 -0
- package/src/tui/screens/MainMenuScreen.js +81 -0
- package/src/tui/screens/MultiSelectScreen.js +17 -0
- package/src/tui/screens/OneShotRunnerScreen.js +186 -0
- package/src/tui/screens/OptionScreen.js +24 -0
- package/src/tui/screens/ResultScreen.js +35 -0
- package/src/tui/screens/StatusScreen.js +70 -0
- package/src/tui/screens/TextEntryScreen.js +115 -0
- package/src/tui/screens/WelcomeScreen.js +66 -0
- package/src/tui/theme.js +69 -0
- package/src/tui-legacy-bridge.js +371 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadOnboardingState } from '../runtime.js';
|
|
2
|
+
|
|
3
|
+
export function humanizeAction(action) {
|
|
4
|
+
switch (action) {
|
|
5
|
+
case 'run-guided-setup':
|
|
6
|
+
return 'Core setup complete';
|
|
7
|
+
case 'create-team':
|
|
8
|
+
return 'Create the first team';
|
|
9
|
+
case 'invite-team-members':
|
|
10
|
+
return 'Add members to a team';
|
|
11
|
+
case 'create-schedule':
|
|
12
|
+
return 'Create the first on-call schedule';
|
|
13
|
+
case 'create-escalation-policy':
|
|
14
|
+
return 'Create the first escalation policy';
|
|
15
|
+
case 'hook-up-monitor':
|
|
16
|
+
return 'Hook up a generic webhook monitor';
|
|
17
|
+
case 'connect-slack':
|
|
18
|
+
return 'Connect Slack in Rootly web';
|
|
19
|
+
case 'create-test-incident':
|
|
20
|
+
return 'Create a test incident in Slack';
|
|
21
|
+
default:
|
|
22
|
+
return 'Continue setup';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getRecommendedNextStepAction() {
|
|
27
|
+
const state = await loadOnboardingState();
|
|
28
|
+
if (!state) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
code: 'NO_AUTH',
|
|
32
|
+
summary: 'No auth context found.',
|
|
33
|
+
data: null
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
summary: 'Computed next recommended setup step.',
|
|
40
|
+
data: {
|
|
41
|
+
nextBestAction: state.onboarding.nextBestAction,
|
|
42
|
+
label: humanizeAction(state.onboarding.nextBestAction),
|
|
43
|
+
readiness: state.onboarding.readiness,
|
|
44
|
+
steps: state.onboarding.steps
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import keytar from 'keytar';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const SERVICE_NAME = 'rootly-wizard';
|
|
10
|
+
const API_TOKEN_ACCOUNT = 'rootly-token';
|
|
11
|
+
const OAUTH_SESSION_ACCOUNT = 'rootly-oauth-session';
|
|
12
|
+
const OAUTH_REGISTRATION_ACCOUNT = 'rootly-oauth-registration';
|
|
13
|
+
const DEFAULT_API_BASE_URL = 'https://api.rootly.com';
|
|
14
|
+
const CALLBACK_PORT = 19797;
|
|
15
|
+
const CALLBACK_PATH = '/callback';
|
|
16
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
17
|
+
const DEFAULT_SCOPES = ['openid', 'profile', 'email', 'all'];
|
|
18
|
+
const REFRESH_SKEW_MS = 30_000;
|
|
19
|
+
const AUTH_REQUEST_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
function escapeHtml(value) {
|
|
22
|
+
return String(value).replace(/[&<>"']/g, (char) => (
|
|
23
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]
|
|
24
|
+
));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The Rootly glyph PNG, inlined so the local callback page is self-contained.
|
|
28
|
+
const LOGO_DATA_URI = (() => {
|
|
29
|
+
try {
|
|
30
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const file = path.join(here, '..', 'assets', 'rootly-logo-glyph-purple.png');
|
|
32
|
+
return `data:image/png;base64,${readFileSync(file).toString('base64')}`;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
// Branded HTML shown in the browser after the OAuth redirect.
|
|
39
|
+
function callbackPage({ ok, title, message }) {
|
|
40
|
+
const statusColor = ok ? '#2FB66B' : '#F4787B';
|
|
41
|
+
const glyph = ok ? '✓' : '✕';
|
|
42
|
+
const logo = LOGO_DATA_URI ? `<img class="logo" src="${LOGO_DATA_URI}" width="92" height="92" alt="Rootly">` : '';
|
|
43
|
+
return `<!DOCTYPE html>
|
|
44
|
+
<html lang="en">
|
|
45
|
+
<head>
|
|
46
|
+
<meta charset="utf-8">
|
|
47
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
48
|
+
<title>Rootly Wizard</title>
|
|
49
|
+
<style>
|
|
50
|
+
:root { color-scheme: light dark; }
|
|
51
|
+
* { box-sizing: border-box; }
|
|
52
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
53
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
54
|
+
background: #F6F5FB; color: #16151D; padding: 24px; }
|
|
55
|
+
.card { width: min(400px, 100%); background: #FFFFFF; border: 1px solid #ECEAF5; border-radius: 20px;
|
|
56
|
+
padding: 48px 40px; box-shadow: 0 18px 50px rgba(75, 60, 130, .12); text-align: center; }
|
|
57
|
+
.logo { display: block; width: 92px; height: 92px; margin: 0 auto 28px; }
|
|
58
|
+
h1 { font-size: 22px; font-weight: 650; letter-spacing: -0.2px; margin: 0 0 10px;
|
|
59
|
+
animation: rise .4s ease-out both; }
|
|
60
|
+
h1 .tick { display: inline-block; color: ${statusColor}; font-weight: 700; margin-right: 7px;
|
|
61
|
+
transform: scale(0); animation: pop .45s cubic-bezier(.2, .8, .3, 1.4) .22s forwards; }
|
|
62
|
+
@keyframes rise { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: none; } }
|
|
63
|
+
@keyframes pop { 0% { transform: scale(0); } 60% { transform: scale(1.3); } 100% { transform: scale(1); } }
|
|
64
|
+
@media (prefers-reduced-motion: reduce) {
|
|
65
|
+
h1, h1 .tick { animation: none; transform: none; opacity: 1; }
|
|
66
|
+
}
|
|
67
|
+
p { margin: 0 auto; max-width: 300px; line-height: 1.6; font-size: 15px; color: #5C5870; }
|
|
68
|
+
.hint { margin-top: 28px; font-size: 13px; color: #8C88A0; }
|
|
69
|
+
@media (prefers-color-scheme: dark) {
|
|
70
|
+
body { background: #0D0C12; color: #ECEAF5; }
|
|
71
|
+
.card { background: #16151D; border-color: #272534; box-shadow: none; }
|
|
72
|
+
p { color: #A6A2B8; }
|
|
73
|
+
.hint { color: #6F6B82; }
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<div class="card">
|
|
79
|
+
${logo}
|
|
80
|
+
<h1><span class="tick">${glyph}</span>${escapeHtml(title)}</h1>
|
|
81
|
+
<p>${escapeHtml(message)}</p>
|
|
82
|
+
<p class="hint">${ok ? 'You can close this tab and return to the terminal.' : 'Return to the terminal and try again.'}</p>
|
|
83
|
+
</div>
|
|
84
|
+
</body>
|
|
85
|
+
</html>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function authBaseUrl(apiBaseUrl = DEFAULT_API_BASE_URL) {
|
|
89
|
+
const url = new URL(apiBaseUrl);
|
|
90
|
+
const host = url.host.replace(/^api\./, '');
|
|
91
|
+
return `${url.protocol}//${host}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function openBrowser(url) {
|
|
95
|
+
const platform = process.platform;
|
|
96
|
+
|
|
97
|
+
if (platform === 'darwin') {
|
|
98
|
+
return spawn('open', [url], { stdio: 'ignore' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (platform === 'win32') {
|
|
102
|
+
return spawn('rundll32', ['url.dll,FileProtocolHandler', url], {
|
|
103
|
+
stdio: 'ignore',
|
|
104
|
+
windowsHide: true
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return spawn('xdg-open', [url], { stdio: 'ignore' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function jsonParse(value) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(value);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function expiresSoon(expiresAt) {
|
|
120
|
+
if (!expiresAt) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const expiry = new Date(expiresAt).getTime();
|
|
125
|
+
if (Number.isNaN(expiry)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return expiry - Date.now() <= REFRESH_SKEW_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function getStoredValue(accountName) {
|
|
133
|
+
try {
|
|
134
|
+
return await keytar.getPassword(SERVICE_NAME, accountName);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function setStoredValue(accountName, value) {
|
|
141
|
+
try {
|
|
142
|
+
await keytar.setPassword(SERVICE_NAME, accountName, value);
|
|
143
|
+
return true;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function deleteStoredValue(accountName) {
|
|
150
|
+
try {
|
|
151
|
+
return await keytar.deletePassword(SERVICE_NAME, accountName);
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadOAuthSession() {
|
|
158
|
+
const raw = await getStoredValue(OAUTH_SESSION_ACCOUNT);
|
|
159
|
+
if (!raw) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return jsonParse(raw);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function storeOAuthSession(session) {
|
|
167
|
+
return setStoredValue(OAUTH_SESSION_ACCOUNT, JSON.stringify(session));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function loadOAuthRegistration() {
|
|
171
|
+
const raw = await getStoredValue(OAUTH_REGISTRATION_ACCOUNT);
|
|
172
|
+
if (!raw) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return jsonParse(raw);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function storeOAuthRegistration(registration) {
|
|
180
|
+
return setStoredValue(OAUTH_REGISTRATION_ACCOUNT, JSON.stringify(registration));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function registerOAuthClient(baseUrl = DEFAULT_API_BASE_URL) {
|
|
184
|
+
const response = await fetch(new URL('/oauth/register', authBaseUrl(baseUrl)), {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
Accept: 'application/json'
|
|
189
|
+
},
|
|
190
|
+
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS),
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
client_name: 'Rootly Wizard',
|
|
193
|
+
redirect_uris: [REDIRECT_URI],
|
|
194
|
+
token_endpoint_auth_method: 'none',
|
|
195
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
196
|
+
response_types: ['code']
|
|
197
|
+
})
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
const text = await response.text().catch(() => '');
|
|
202
|
+
throw new Error(`OAuth registration failed: ${response.status}${text ? ` - ${text}` : ''}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const payload = await response.json();
|
|
206
|
+
const clientId = payload?.client_id;
|
|
207
|
+
if (!clientId) {
|
|
208
|
+
throw new Error('OAuth registration response did not include client_id');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const scopes = typeof payload?.scope === 'string' && payload.scope.trim()
|
|
212
|
+
? payload.scope.trim().split(/\s+/)
|
|
213
|
+
: DEFAULT_SCOPES;
|
|
214
|
+
|
|
215
|
+
return { clientId, scopes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function resolveOAuthRegistration(baseUrl = DEFAULT_API_BASE_URL) {
|
|
219
|
+
const cached = await loadOAuthRegistration();
|
|
220
|
+
if (cached?.clientId) {
|
|
221
|
+
return {
|
|
222
|
+
clientId: cached.clientId,
|
|
223
|
+
scopes: Array.isArray(cached.scopes) && cached.scopes.length ? cached.scopes : DEFAULT_SCOPES
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const registration = await registerOAuthClient(baseUrl);
|
|
228
|
+
await storeOAuthRegistration(registration);
|
|
229
|
+
return registration;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function exchangeOAuthCode({ code, codeVerifier, clientId, baseUrl = DEFAULT_API_BASE_URL }) {
|
|
233
|
+
const body = new URLSearchParams({
|
|
234
|
+
grant_type: 'authorization_code',
|
|
235
|
+
client_id: clientId,
|
|
236
|
+
code,
|
|
237
|
+
redirect_uri: REDIRECT_URI,
|
|
238
|
+
code_verifier: codeVerifier
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const response = await fetch(new URL('/oauth/token', authBaseUrl(baseUrl)), {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: {
|
|
244
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
245
|
+
Accept: 'application/json'
|
|
246
|
+
},
|
|
247
|
+
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS),
|
|
248
|
+
body
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const payload = await response.json().catch(() => null);
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
throw new Error(payload?.error_description || payload?.error || `OAuth token exchange failed (${response.status})`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return payload;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function refreshOAuthAccessToken(session) {
|
|
260
|
+
const body = new URLSearchParams({
|
|
261
|
+
grant_type: 'refresh_token',
|
|
262
|
+
client_id: session.clientId,
|
|
263
|
+
refresh_token: session.refreshToken
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const response = await fetch(new URL('/oauth/token', authBaseUrl(session.baseUrl || DEFAULT_API_BASE_URL)), {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: {
|
|
269
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
270
|
+
Accept: 'application/json'
|
|
271
|
+
},
|
|
272
|
+
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS),
|
|
273
|
+
body
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const payload = await response.json().catch(() => null);
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
throw new Error(payload?.error_description || payload?.error || `OAuth refresh failed (${response.status})`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const refreshed = {
|
|
282
|
+
...session,
|
|
283
|
+
accessToken: payload.access_token,
|
|
284
|
+
refreshToken: payload.refresh_token || session.refreshToken,
|
|
285
|
+
tokenType: payload.token_type || session.tokenType || 'Bearer',
|
|
286
|
+
expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000).toISOString() : session.expiresAt
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
await storeOAuthSession(refreshed);
|
|
290
|
+
return refreshed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function sessionToStoredShape(baseUrl, registration, tokenPayload) {
|
|
294
|
+
return {
|
|
295
|
+
kind: 'oauth',
|
|
296
|
+
baseUrl,
|
|
297
|
+
clientId: registration.clientId,
|
|
298
|
+
scopes: registration.scopes,
|
|
299
|
+
accessToken: tokenPayload.access_token,
|
|
300
|
+
refreshToken: tokenPayload.refresh_token,
|
|
301
|
+
tokenType: tokenPayload.token_type || 'Bearer',
|
|
302
|
+
expiresAt: tokenPayload.expires_in
|
|
303
|
+
? new Date(Date.now() + tokenPayload.expires_in * 1000).toISOString()
|
|
304
|
+
: null
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function getStoredToken() {
|
|
309
|
+
if (process.env.ROOTLY_TOKEN?.trim()) {
|
|
310
|
+
return process.env.ROOTLY_TOKEN.trim();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const apiToken = await getStoredValue(API_TOKEN_ACCOUNT);
|
|
314
|
+
if (apiToken) {
|
|
315
|
+
return apiToken;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const oauthSession = await loadOAuthSession();
|
|
319
|
+
if (!oauthSession?.accessToken) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!expiresSoon(oauthSession.expiresAt)) {
|
|
324
|
+
return oauthSession.accessToken;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!oauthSession.refreshToken) {
|
|
328
|
+
return oauthSession.accessToken;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const refreshed = await refreshOAuthAccessToken(oauthSession);
|
|
333
|
+
return refreshed.accessToken;
|
|
334
|
+
} catch {
|
|
335
|
+
return oauthSession.accessToken;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function getAuthSummary() {
|
|
340
|
+
if (process.env.ROOTLY_TOKEN?.trim()) {
|
|
341
|
+
return {
|
|
342
|
+
mode: 'env-token',
|
|
343
|
+
label: 'Using ROOTLY_TOKEN from environment'
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const apiToken = await getStoredValue(API_TOKEN_ACCOUNT);
|
|
348
|
+
if (apiToken) {
|
|
349
|
+
return {
|
|
350
|
+
mode: 'stored-token',
|
|
351
|
+
label: 'Stored API key found in keychain'
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const oauthSession = await loadOAuthSession();
|
|
356
|
+
if (oauthSession?.accessToken) {
|
|
357
|
+
return {
|
|
358
|
+
mode: 'oauth',
|
|
359
|
+
label: 'Stored Rootly browser session found'
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function storeToken(token) {
|
|
367
|
+
if (process.env.ROOTLY_TOKEN?.trim()) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
await deleteStoredValue(OAUTH_SESSION_ACCOUNT);
|
|
372
|
+
return setStoredValue(API_TOKEN_ACCOUNT, token);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function deleteToken() {
|
|
376
|
+
if (process.env.ROOTLY_TOKEN?.trim()) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const [deletedApiToken, deletedOAuthSession, deletedRegistration] = await Promise.all([
|
|
381
|
+
deleteStoredValue(API_TOKEN_ACCOUNT),
|
|
382
|
+
deleteStoredValue(OAUTH_SESSION_ACCOUNT),
|
|
383
|
+
deleteStoredValue(OAUTH_REGISTRATION_ACCOUNT)
|
|
384
|
+
]);
|
|
385
|
+
|
|
386
|
+
return deletedApiToken || deletedOAuthSession || deletedRegistration;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function validateToken(token, baseUrl = DEFAULT_API_BASE_URL) {
|
|
390
|
+
const response = await fetch(new URL('/v1/users/me', baseUrl), {
|
|
391
|
+
headers: {
|
|
392
|
+
Authorization: `Bearer ${token}`,
|
|
393
|
+
Accept: 'application/json'
|
|
394
|
+
},
|
|
395
|
+
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS)
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (!response.ok) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return response.json();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function startOAuthLogin(baseUrl = DEFAULT_API_BASE_URL) {
|
|
406
|
+
const registration = await resolveOAuthRegistration(baseUrl);
|
|
407
|
+
const state = crypto.randomBytes(32).toString('base64url');
|
|
408
|
+
const codeVerifier = crypto.randomBytes(48).toString('base64url');
|
|
409
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
410
|
+
|
|
411
|
+
const authorizeUrl = new URL('/oauth/authorize', authBaseUrl(baseUrl));
|
|
412
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
413
|
+
authorizeUrl.searchParams.set('client_id', registration.clientId);
|
|
414
|
+
authorizeUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
415
|
+
if (Array.isArray(registration.scopes) && registration.scopes.length > 0) {
|
|
416
|
+
authorizeUrl.searchParams.set('scope', registration.scopes.join(' '));
|
|
417
|
+
}
|
|
418
|
+
authorizeUrl.searchParams.set('state', state);
|
|
419
|
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
|
|
420
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
421
|
+
|
|
422
|
+
const callbackResult = await new Promise((resolve, reject) => {
|
|
423
|
+
let settled = false;
|
|
424
|
+
let timeoutId;
|
|
425
|
+
const server = http.createServer((req, res) => {
|
|
426
|
+
const requestUrl = new URL(req.url || '/', REDIRECT_URI);
|
|
427
|
+
if (requestUrl.pathname !== CALLBACK_PATH) {
|
|
428
|
+
res.writeHead(404).end('Not found');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const finish = (fn) => {
|
|
433
|
+
if (settled) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
settled = true;
|
|
438
|
+
clearTimeout(timeoutId);
|
|
439
|
+
setTimeout(() => server.close(), 50).unref();
|
|
440
|
+
fn();
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
if (requestUrl.searchParams.get('state') !== state) {
|
|
444
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
445
|
+
res.end(callbackPage({
|
|
446
|
+
ok: false,
|
|
447
|
+
title: 'Sign-in failed',
|
|
448
|
+
message: 'The sign-in request could not be verified (state mismatch). Return to the terminal and try again.'
|
|
449
|
+
}));
|
|
450
|
+
finish(() => reject(new Error('OAuth state mismatch')));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const error = requestUrl.searchParams.get('error');
|
|
455
|
+
if (error) {
|
|
456
|
+
const description = requestUrl.searchParams.get('error_description') || error;
|
|
457
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
458
|
+
res.end(callbackPage({ ok: false, title: 'Sign-in failed', message: description }));
|
|
459
|
+
finish(() => reject(new Error(description)));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const code = requestUrl.searchParams.get('code');
|
|
464
|
+
if (!code) {
|
|
465
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
466
|
+
res.end(callbackPage({
|
|
467
|
+
ok: false,
|
|
468
|
+
title: 'Sign-in failed',
|
|
469
|
+
message: 'No authorization code was returned. Return to the terminal and try again.'
|
|
470
|
+
}));
|
|
471
|
+
finish(() => reject(new Error('Missing OAuth authorization code')));
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
476
|
+
res.end(callbackPage({
|
|
477
|
+
ok: true,
|
|
478
|
+
title: 'You\'re signed in',
|
|
479
|
+
message: 'Rootly Wizard is finishing sign-in in your terminal.'
|
|
480
|
+
}));
|
|
481
|
+
finish(() => resolve({ code }));
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
server.on('error', (error) => {
|
|
485
|
+
if (settled) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
settled = true;
|
|
490
|
+
clearTimeout(timeoutId);
|
|
491
|
+
reject(error);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
server.listen(CALLBACK_PORT, '127.0.0.1', async () => {
|
|
495
|
+
try {
|
|
496
|
+
const child = openBrowser(authorizeUrl.toString());
|
|
497
|
+
child.on('error', (error) => {
|
|
498
|
+
if (settled) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
settled = true;
|
|
503
|
+
clearTimeout(timeoutId);
|
|
504
|
+
server.close();
|
|
505
|
+
reject(error);
|
|
506
|
+
});
|
|
507
|
+
child.unref();
|
|
508
|
+
} catch (error) {
|
|
509
|
+
settled = true;
|
|
510
|
+
clearTimeout(timeoutId);
|
|
511
|
+
server.close();
|
|
512
|
+
reject(error);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
timeoutId = setTimeout(() => {
|
|
517
|
+
if (settled) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
settled = true;
|
|
522
|
+
server.close();
|
|
523
|
+
reject(new Error('OAuth login timed out after 5 minutes'));
|
|
524
|
+
}, 5 * 60 * 1000);
|
|
525
|
+
timeoutId.unref();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const tokenPayload = await exchangeOAuthCode({
|
|
529
|
+
code: callbackResult.code,
|
|
530
|
+
codeVerifier,
|
|
531
|
+
clientId: registration.clientId,
|
|
532
|
+
baseUrl
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const session = sessionToStoredShape(baseUrl, registration, tokenPayload);
|
|
536
|
+
const stored = await storeOAuthSession(session);
|
|
537
|
+
if (!stored) {
|
|
538
|
+
throw new Error('OAuth login succeeded, but the browser session could not be stored securely');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const userPayload = await validateToken(session.accessToken, baseUrl);
|
|
542
|
+
if (!userPayload) {
|
|
543
|
+
throw new Error('OAuth login succeeded, but token validation failed');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
await deleteStoredValue(API_TOKEN_ACCOUNT);
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
stored,
|
|
550
|
+
userPayload,
|
|
551
|
+
accessToken: session.accessToken
|
|
552
|
+
};
|
|
553
|
+
}
|