@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/assets/rootly-logo-glyph-purple.png +0 -0
  4. package/assets/rootly-logo-glyph.png +0 -0
  5. package/assets/welcome-screen.png +0 -0
  6. package/package.json +47 -0
  7. package/src/actions/guided.js +87 -0
  8. package/src/actions/inspect.js +341 -0
  9. package/src/actions/integrations.js +75 -0
  10. package/src/actions/mcp.js +52 -0
  11. package/src/actions/oneshot.js +243 -0
  12. package/src/actions/phone.js +122 -0
  13. package/src/actions/registry.js +373 -0
  14. package/src/actions/setup.js +574 -0
  15. package/src/actions/testing.js +141 -0
  16. package/src/actions/workflow.js +47 -0
  17. package/src/auth.js +553 -0
  18. package/src/cli.js +199 -0
  19. package/src/detect-state.js +232 -0
  20. package/src/format.js +43 -0
  21. package/src/mcp.js +254 -0
  22. package/src/rootly-api.js +325 -0
  23. package/src/runtime.js +55 -0
  24. package/src/tui/components/AppShell.js +68 -0
  25. package/src/tui/components/Banner.js +145 -0
  26. package/src/tui/components/BigText.js +46 -0
  27. package/src/tui/components/Celebration.js +47 -0
  28. package/src/tui/components/KeyValueList.js +22 -0
  29. package/src/tui/components/MenuList.js +170 -0
  30. package/src/tui/components/MultiSelectList.js +181 -0
  31. package/src/tui/components/NoticeBox.js +56 -0
  32. package/src/tui/components/SlideReveal.js +52 -0
  33. package/src/tui/index.js +2078 -0
  34. package/src/tui/screens/ListScreen.js +29 -0
  35. package/src/tui/screens/LoadFailedScreen.js +30 -0
  36. package/src/tui/screens/LoadingScreen.js +32 -0
  37. package/src/tui/screens/MainMenuScreen.js +81 -0
  38. package/src/tui/screens/MultiSelectScreen.js +17 -0
  39. package/src/tui/screens/OneShotRunnerScreen.js +186 -0
  40. package/src/tui/screens/OptionScreen.js +24 -0
  41. package/src/tui/screens/ResultScreen.js +35 -0
  42. package/src/tui/screens/StatusScreen.js +70 -0
  43. package/src/tui/screens/TextEntryScreen.js +115 -0
  44. package/src/tui/screens/WelcomeScreen.js +66 -0
  45. package/src/tui/theme.js +69 -0
  46. package/src/tui-legacy-bridge.js +371 -0
@@ -0,0 +1,2078 @@
1
+ import { createElement as h, useEffect, useState } from 'react';
2
+ import { render, useApp, Box } from 'ink';
3
+ import { palette } from './theme.js';
4
+ import { friendlyError, formatPhone, hyperlink } from '../format.js';
5
+ import { BigText } from './components/BigText.js';
6
+ import { WelcomeScreen } from './screens/WelcomeScreen.js';
7
+ import { MainMenuScreen } from './screens/MainMenuScreen.js';
8
+ import { StatusScreen } from './screens/StatusScreen.js';
9
+ import { ListScreen } from './screens/ListScreen.js';
10
+ import { OptionScreen } from './screens/OptionScreen.js';
11
+ import { LoadingScreen } from './screens/LoadingScreen.js';
12
+ import { LoadFailedScreen } from './screens/LoadFailedScreen.js';
13
+ import { TextEntryScreen } from './screens/TextEntryScreen.js';
14
+ import { ResultScreen } from './screens/ResultScreen.js';
15
+ import { MultiSelectScreen } from './screens/MultiSelectScreen.js';
16
+ import { OneShotRunnerScreen } from './screens/OneShotRunnerScreen.js';
17
+ import { Celebration } from './components/Celebration.js';
18
+ import {
19
+ loadAuthContextForTui,
20
+ loadOnboardingStateInteractive,
21
+ loadTeamsForTui,
22
+ loadSchedulesForTui,
23
+ loadEscalationPoliciesForTui,
24
+ authenticateWithApiTokenForTui,
25
+ authenticateWithBrowserForTui,
26
+ createTeamForTui,
27
+ addTeamMembersForTui,
28
+ addTeamMembersByIdsForTui,
29
+ loadAddableUsersForTui,
30
+ loadDirectoryUsersForTui,
31
+ createScheduleForTui,
32
+ createEscalationPolicyForTui,
33
+ createAlertSourceForTui,
34
+ createStatusPageForTui,
35
+ loadStatusPageComponentsForTui,
36
+ createCustomComponentForTui,
37
+ loadStatusPagesForTui,
38
+ publishStatusPageForTui,
39
+ updateStatusPageForTui,
40
+ createTestAlertForTui,
41
+ createTestIncidentForTui,
42
+ deleteTokenForTui,
43
+ runOneShotSetupForTui,
44
+ startPhoneVerificationForTui,
45
+ confirmPhoneVerificationForTui,
46
+ loadCurrentUserPhoneForTui,
47
+ startWebHandoffForTui,
48
+ openExternalUrlForTui,
49
+ previewMcpForTui,
50
+ applyMcpForTui,
51
+ loadTeamMembersForTui
52
+ } from '../tui-legacy-bridge.js';
53
+
54
+ const ENTER_ALT_SCREEN = '\u001b[?1049h';
55
+ const LEAVE_ALT_SCREEN = '\u001b[?1049l';
56
+ const HIDE_CURSOR = '\u001b[?25l';
57
+ const SHOW_CURSOR = '\u001b[?25h';
58
+ const CLEAR_SCREEN = '\u001b[2J';
59
+ const CLEAR_SCROLLBACK = '\u001b[3J';
60
+ const CURSOR_HOME = '\u001b[H';
61
+
62
+ const CEO_CAL_URL = 'https://cal.link/jj';
63
+ const SALES_DEMO_URL = 'https://rootly.com/demo?utm_term=rootly&utm_campaign=Rootly_Brand_Search_USA&utm_source=google&utm_medium=ppc&utm_content=featuresad&gad_source=1&gad_campaignid=22513408256&gbraid=0AAAAACV5CgFej7JQ4Q1NvqRnqhOLGHiP8';
64
+ const APP_BASE_URL = (process.env.ROOTLY_APP_URL?.trim().replace(/\/$/, '')) || 'https://rootly.com';
65
+ const SIGNUP_URL = `${APP_BASE_URL}/users/sign_up`;
66
+ const STATUS_PAGES_URL = `${APP_BASE_URL}/account/status-pages`;
67
+
68
+ function enterAltScreen() {
69
+ if (!process.stdout.isTTY) return;
70
+ process.stdout.write(`${CLEAR_SCROLLBACK}${ENTER_ALT_SCREEN}${HIDE_CURSOR}${CLEAR_SCREEN}${CURSOR_HOME}`);
71
+ }
72
+
73
+ function leaveAltScreen() {
74
+ if (!process.stdout.isTTY) return;
75
+ process.stdout.write(`${SHOW_CURSOR}${LEAVE_ALT_SCREEN}${CLEAR_SCROLLBACK}`);
76
+ }
77
+
78
+ function InkWizardApp({ onExit }) {
79
+ const { exit } = useApp();
80
+ const [screen, setScreen] = useState('welcome');
81
+ const [state, setState] = useState(null);
82
+ const [teamsData, setTeamsData] = useState(null);
83
+ const [schedulesData, setSchedulesData] = useState(null);
84
+ const [policiesData, setPoliciesData] = useState(null);
85
+ const [authContext, setAuthContext] = useState(null);
86
+ const [loading, setLoading] = useState(false);
87
+ const [resultScreen, setResultScreen] = useState(null);
88
+ const [selectedTeam, setSelectedTeam] = useState(null);
89
+ const [formState, setFormState] = useState({});
90
+ const [mcpPreview, setMcpPreview] = useState(null);
91
+ const [teamMembersData, setTeamMembersData] = useState(null);
92
+ const [addableUsers, setAddableUsers] = useState(null);
93
+ const [directoryUsers, setDirectoryUsers] = useState(null);
94
+ const [spComponents, setSpComponents] = useState(null);
95
+ const [spExisting, setSpExisting] = useState(null);
96
+ const [userPhone, setUserPhone] = useState(null);
97
+ const [authRecovery, setAuthRecovery] = useState(null);
98
+
99
+ useEffect(() => {
100
+ let cancelled = false;
101
+ if (!['menu', 'status', 'inspect', 'teams', 'schedules', 'policies', 'schedule-members', 'add-members-picker', 'one-shot-members'].includes(screen)) return undefined;
102
+
103
+ void (async () => {
104
+ // Reuse cached values across navigation so returning to the menu is
105
+ // instant. The keychain check and the workspace sweep only run when their
106
+ // cache is empty; mutations clear the cache (see clearWorkspaceCache),
107
+ // which forces a fresh load on the next visit.
108
+ let nextAuth = authContext;
109
+ if (!nextAuth) {
110
+ setLoading(true);
111
+ nextAuth = await loadAuthContextForTui();
112
+ if (cancelled) return;
113
+ setAuthContext(nextAuth);
114
+ }
115
+ if (!nextAuth?.hasAuth) {
116
+ if (!cancelled) {
117
+ setLoading(false);
118
+ setScreen('auth-method');
119
+ }
120
+ return;
121
+ }
122
+
123
+ if (!state) {
124
+ setLoading(true);
125
+ const stateResult = await loadOnboardingStateInteractive();
126
+ if (cancelled) return;
127
+ if (!stateResult.ok) {
128
+ setLoading(false);
129
+ if (stateResult.reason === 'auth-capability') {
130
+ setAuthRecovery(stateResult);
131
+ setScreen('auth-recovery');
132
+ } else {
133
+ setScreen('load-failed');
134
+ }
135
+ return;
136
+ }
137
+ setState(stateResult.state);
138
+ }
139
+
140
+ if (screen === 'teams' && !teamsData) {
141
+ setLoading(true);
142
+ const nextTeams = await loadTeamsForTui();
143
+ if (!cancelled) setTeamsData(nextTeams);
144
+ }
145
+ if (screen === 'schedules' && !schedulesData) {
146
+ setLoading(true);
147
+ const nextSchedules = await loadSchedulesForTui();
148
+ if (!cancelled) setSchedulesData(nextSchedules);
149
+ }
150
+ if (screen === 'policies' && !policiesData) {
151
+ setLoading(true);
152
+ const nextPolicies = await loadEscalationPoliciesForTui();
153
+ if (!cancelled) setPoliciesData(nextPolicies);
154
+ }
155
+ if (screen === 'schedule-members' && selectedTeam?.id && !teamMembersData) {
156
+ setLoading(true);
157
+ const nextMembers = await loadTeamMembersForTui(selectedTeam.id);
158
+ if (!cancelled) setTeamMembersData(nextMembers);
159
+ }
160
+ if (screen === 'add-members-picker' && selectedTeam?.id && !addableUsers) {
161
+ setLoading(true);
162
+ const nextAddable = await loadAddableUsersForTui(selectedTeam.id);
163
+ if (!cancelled) setAddableUsers(nextAddable);
164
+ }
165
+ if (screen === 'one-shot-members' && !directoryUsers) {
166
+ setLoading(true);
167
+ const dir = await loadDirectoryUsersForTui();
168
+ if (!cancelled) setDirectoryUsers(dir);
169
+ }
170
+ if (!cancelled) {
171
+ setLoading(false);
172
+ }
173
+ })();
174
+
175
+ return () => {
176
+ cancelled = true;
177
+ };
178
+ }, [screen]);
179
+
180
+ // Detect existing status pages at the start of the guided flow.
181
+ useEffect(() => {
182
+ if (screen !== 'sp-start' || spExisting !== null) return undefined;
183
+ let cancelled = false;
184
+ setLoading(true);
185
+ void (async () => {
186
+ const data = await loadStatusPagesForTui();
187
+ if (!cancelled) {
188
+ setSpExisting(data || { pages: [] });
189
+ setLoading(false);
190
+ }
191
+ })();
192
+ return () => {
193
+ cancelled = true;
194
+ };
195
+ }, [screen, spExisting]);
196
+
197
+ // "Set up your public status page" jumps straight to the seeded public page
198
+ // (no picker, no create-vs-customize branch). If none exists, fall to create.
199
+ useEffect(() => {
200
+ if (screen !== 'sp-start' || spExisting === null || !formState.sp?.directPublic) return undefined;
201
+ const publicPage = (spExisting.pages || []).find((p) => p.public);
202
+ if (publicPage) {
203
+ // Walk them through editing the existing public page (update, not create).
204
+ setFormState((prev) => ({ ...prev, sp: { ...(prev.sp || {}), editId: publicPage.id, existingPage: publicPage, title: publicPage.title } }));
205
+ }
206
+ setScreen('sp-name'); // no public page → create one; otherwise walk through editing it
207
+ return undefined;
208
+ }, [screen, spExisting]);
209
+
210
+ // Load services/functionalities to offer as status-page components.
211
+ useEffect(() => {
212
+ if ((screen !== 'sp-components' && screen !== 'sp-edit-components') || spComponents !== null) return undefined;
213
+ let cancelled = false;
214
+ setLoading(true);
215
+ void (async () => {
216
+ const data = await loadStatusPageComponentsForTui();
217
+ if (!cancelled) {
218
+ setSpComponents(data || { components: [] });
219
+ setLoading(false);
220
+ }
221
+ })();
222
+ return () => {
223
+ cancelled = true;
224
+ };
225
+ }, [screen, spComponents]);
226
+
227
+ // Load the signed-in user's existing phone (for the pre-flight screen) so we
228
+ // can show it instead of offering to add one.
229
+ useEffect(() => {
230
+ if (screen !== 'one-shot-prereqs' || userPhone !== null) return undefined;
231
+ let cancelled = false;
232
+ void (async () => {
233
+ const info = await loadCurrentUserPhoneForTui();
234
+ if (!cancelled) setUserPhone(info || { hasPhone: false, phone: null });
235
+ })();
236
+ return () => {
237
+ cancelled = true;
238
+ };
239
+ }, [screen, userPhone]);
240
+
241
+ // Landing on a menu ends any in-progress flow: clear the picked team and the
242
+ // pending-action form so a later flow can't act on a stale team selection.
243
+ useEffect(() => {
244
+ if (screen === 'menu' || screen === 'general-menu') {
245
+ setSelectedTeam(null);
246
+ setFormState({});
247
+ }
248
+ }, [screen]);
249
+
250
+ const leave = () => {
251
+ onExit?.();
252
+ exit();
253
+ };
254
+
255
+ // Drop the cached workspace snapshot so the next menu/data visit refetches.
256
+ // Called after any action that may have changed the workspace.
257
+ const clearWorkspaceCache = () => {
258
+ setState(null);
259
+ setTeamsData(null);
260
+ setSchedulesData(null);
261
+ setPoliciesData(null);
262
+ setTeamMembersData(null);
263
+ setAddableUsers(null);
264
+ };
265
+
266
+ // Loading takes precedence over the current screen, so a loader shows even
267
+ // while we're still "on" the auth-token / data screens.
268
+ if (loading) {
269
+ if (screen === 'auth-token') {
270
+ return h(LoadingScreen, {
271
+ title: 'Signing you in…',
272
+ detail: 'Verifying your token with Rootly.'
273
+ });
274
+ }
275
+ return h(LoadingScreen, {
276
+ title: screen === 'menu' ? 'Loading Rootly workspace…' : 'Loading Rootly data…',
277
+ detail: authContext?.label || 'Checking your current sign-in and workspace state.'
278
+ });
279
+ }
280
+
281
+ if (screen === 'welcome') {
282
+ return h(WelcomeScreen, {
283
+ lines: [
284
+ 'Get from an empty workspace to incident-ready on-call in minutes.',
285
+ '',
286
+ 'A handful of guided steps covers your teams, schedules, escalation, alerting, and integrations. No docs required.'
287
+ ],
288
+ // Head to the menu, whose effect checks the keychain for a stored
289
+ // sign-in: returning users skip auth entirely, and only an unauthed
290
+ // session gets routed on to the sign-in chooser.
291
+ onContinue: () => setScreen('menu'),
292
+ onExit: leave
293
+ });
294
+ }
295
+
296
+ if (screen === 'auth-method') {
297
+ const hasAuth = Boolean(authContext?.hasAuth);
298
+ return h(OptionScreen, {
299
+ title: 'Sign in with Rootly',
300
+ lines: hasAuth
301
+ ? [
302
+ authContext?.label || 'A Rootly sign-in is already stored on this machine.',
303
+ '',
304
+ 'Keep it, or sign in another way.'
305
+ ]
306
+ : [
307
+ 'Choose how to sign in to Rootly.',
308
+ '',
309
+ 'Browser sign-in uses OAuth. An API token works for the full setup and automation.'
310
+ ],
311
+ options: [
312
+ ...(hasAuth ? [{ label: 'Keep current sign-in', value: 'keep' }] : []),
313
+ { label: 'Browser sign-in', value: 'browser' },
314
+ { label: 'API token', value: 'token' },
315
+ ...(hasAuth ? [] : [{ label: 'Create a Rootly account', value: 'signup' }]),
316
+ { label: 'Exit', value: 'exit' }
317
+ ],
318
+ onSelect: async (option) => {
319
+ if (option.value === 'keep') {
320
+ setScreen('menu');
321
+ return;
322
+ }
323
+ if (option.value === 'exit') {
324
+ // Exit, with the option to keep or delete the stored sign-in.
325
+ setScreen('exit-confirm');
326
+ return;
327
+ }
328
+ if (option.value === 'signup') {
329
+ // No headless signup API — hand off to the web (it's bot-protected and
330
+ // needs email confirmation). They come back and sign in after.
331
+ setLoading(true);
332
+ const opened = await openExternalUrlForTui(SIGNUP_URL);
333
+ setLoading(false);
334
+ setResultScreen({
335
+ title: 'Create a Rootly account',
336
+ lines: [
337
+ opened?.opened
338
+ ? 'Opened the Rootly sign-up page in your browser.'
339
+ : 'Open this link to create your Rootly account:',
340
+ '',
341
+ SIGNUP_URL,
342
+ '',
343
+ 'Once your account is set up, come back and sign in with an API token.'
344
+ ],
345
+ next: 'auth-method'
346
+ });
347
+ setScreen('result');
348
+ return;
349
+ }
350
+ if (option.value === 'token') {
351
+ setScreen('auth-token');
352
+ return;
353
+ }
354
+ if (option.value === 'browser') {
355
+ setLoading(true);
356
+ const result = await authenticateWithBrowserForTui();
357
+ setLoading(false);
358
+ if (result.ok) {
359
+ setAuthContext(null);
360
+ clearWorkspaceCache();
361
+ setScreen('menu');
362
+ return;
363
+ }
364
+ setResultScreen({ title: 'Sign-in failed', lines: [friendlyError(result.summary)], next: 'auth-method' });
365
+ setScreen('result');
366
+ }
367
+ },
368
+ onBack: () => setScreen(hasAuth ? 'menu' : 'welcome')
369
+ });
370
+ }
371
+
372
+ if (screen === 'auth-token') {
373
+ const hasAuth = Boolean(authContext?.hasAuth);
374
+ return h(TextEntryScreen, {
375
+ title: 'Authorize with a Rootly API token',
376
+ prompt: hasAuth
377
+ ? 'Paste a token to replace your stored one.'
378
+ : 'Paste your Rootly API token below.',
379
+ lines: [
380
+ 'Need a token? Create one in Rootly:',
381
+ 'Organization Settings → API Keys → New API Key',
382
+ 'Use a Global key with write access'
383
+ ],
384
+ link: 'Docs: https://docs.rootly.com/api-reference/overview',
385
+ placeholder: 'token',
386
+ hidden: true,
387
+ onSubmit: async (value) => {
388
+ setLoading(true);
389
+ const result = await authenticateWithApiTokenForTui(value);
390
+ if (result.ok) {
391
+ setAuthContext(null);
392
+ clearWorkspaceCache();
393
+ // False timeout so the sign-in loader is visible instead of flashing by.
394
+ await new Promise((resolve) => setTimeout(resolve, 2300));
395
+ setLoading(false);
396
+ setScreen('menu');
397
+ return;
398
+ }
399
+ setLoading(false);
400
+ setResultScreen({
401
+ title: 'Auth failed',
402
+ lines: [friendlyError(result.summary)],
403
+ next: 'auth-token'
404
+ });
405
+ setScreen('result');
406
+ },
407
+ onBack: () => setScreen('auth-method')
408
+ });
409
+ }
410
+
411
+ if (screen === 'load-failed') {
412
+ return h(LoadFailedScreen, {
413
+ onBack: () => setScreen('menu'),
414
+ onExit: leave
415
+ });
416
+ }
417
+
418
+ if (screen === 'auth-recovery') {
419
+ return h(OptionScreen, {
420
+ title: 'Auth needs attention',
421
+ lines: [
422
+ authRecovery?.label || 'A stored Rootly sign-in was found.',
423
+ '',
424
+ authRecovery?.isBrowserSession
425
+ ? 'Browser sign-in completed, but this OAuth session cannot read the workspace setup APIs yet.'
426
+ : 'The stored sign-in could not read this Rootly workspace.',
427
+ '',
428
+ authRecovery?.isBrowserSession
429
+ ? 'Sign in with an API token for now, or retry browser sign-in after Rootly grants workspace API access.'
430
+ : 'Sign in again to continue.'
431
+ ],
432
+ options: [
433
+ { label: 'Sign in again', value: 'reauth' },
434
+ { label: 'Exit wizard', value: 'exit' }
435
+ ],
436
+ onSelect: async (option) => {
437
+ if (option.value === 'exit') {
438
+ leave();
439
+ return;
440
+ }
441
+ await deleteTokenForTui();
442
+ setAuthRecovery(null);
443
+ setAuthContext(null);
444
+ setScreen('auth-method');
445
+ },
446
+ onBack: leave
447
+ });
448
+ }
449
+
450
+ if (screen === 'result' && resultScreen) {
451
+ return h(ResultScreen, {
452
+ title: resultScreen.title,
453
+ lines: resultScreen.lines,
454
+ actions: resultScreen.actions || [],
455
+ onContinue: () => {
456
+ // Results that return to the menu may follow a mutation; refresh the
457
+ // workspace snapshot so coverage reflects the change.
458
+ if (resultScreen.next === 'menu') clearWorkspaceCache();
459
+ setScreen(resultScreen.next);
460
+ },
461
+ onExit: leave,
462
+ continueLabel: resultScreen.continueLabel || (resultScreen.next === 'menu' ? 'Continue' : 'Try again')
463
+ });
464
+ }
465
+
466
+ if (screen === 'status') {
467
+ return h(StatusScreen, {
468
+ state,
469
+ onBack: () => setScreen('menu')
470
+ });
471
+ }
472
+
473
+ if (screen === 'general-menu') {
474
+ return h(OptionScreen, {
475
+ title: 'General setup',
476
+ context: 'menu',
477
+ lines: ['Everything beyond the recommended next step.'],
478
+ options: [
479
+ { label: 'Setup: teams, members, schedules, escalation', value: 'setup' },
480
+ { label: 'Integrations: Slack, alert sources, vendors', value: 'integrations' },
481
+ { label: 'Verify: test alerting and incidents', value: 'verify' },
482
+ { label: 'Inspect: status, teams, schedules', value: 'inspect' },
483
+ { label: 'Set up MCP / IDE', value: 'mcp' },
484
+ { label: 'Chat with us', value: 'chat' },
485
+ { label: 'Back', value: 'back' }
486
+ ],
487
+ onSelect: (option) => {
488
+ if (option.value === 'back') {
489
+ setScreen('menu');
490
+ return;
491
+ }
492
+ if (option.value === 'chat') {
493
+ setScreen('chat-menu');
494
+ return;
495
+ }
496
+ if (option.value === 'setup') {
497
+ setScreen('setup-menu');
498
+ return;
499
+ }
500
+ if (option.value === 'integrations') {
501
+ setScreen('integrations-menu');
502
+ return;
503
+ }
504
+ if (option.value === 'verify') {
505
+ setScreen('verify-menu');
506
+ return;
507
+ }
508
+ if (option.value === 'inspect') {
509
+ setScreen('inspect');
510
+ return;
511
+ }
512
+ if (option.value === 'mcp') {
513
+ setScreen('mcp-menu');
514
+ return;
515
+ }
516
+ setScreen('menu');
517
+ },
518
+ onBack: () => setScreen('menu')
519
+ });
520
+ }
521
+
522
+ if (screen === 'chat-menu') {
523
+ return h(OptionScreen, {
524
+ title: 'Chat with us',
525
+ lines: ['Talk to the Rootly team.'],
526
+ options: [
527
+ { label: 'Talk to our founder JJ (seriously)', value: 'chat-ceo' },
528
+ { label: 'Book a demo with sales', value: 'book-demo' },
529
+ { label: 'Back', value: 'back' }
530
+ ],
531
+ onSelect: async (option) => {
532
+ if (option.value === 'back') {
533
+ setScreen('general-menu');
534
+ return;
535
+ }
536
+ const handoff = option.value === 'book-demo'
537
+ ? {
538
+ url: SALES_DEMO_URL,
539
+ title: 'Book a demo with sales',
540
+ opened: 'Opened the Rootly demo booking page in your browser.',
541
+ fallback: 'Open this link to book a demo with sales:'
542
+ }
543
+ : {
544
+ url: CEO_CAL_URL,
545
+ title: 'Talk to our founder JJ',
546
+ opened: "Opened JJ's calendar in your browser.",
547
+ fallback: 'Open this link to book a time with JJ:'
548
+ };
549
+ setLoading(true);
550
+ const result = await openExternalUrlForTui(handoff.url);
551
+ setLoading(false);
552
+ setResultScreen({
553
+ title: handoff.title,
554
+ lines: result.opened
555
+ ? [handoff.opened]
556
+ : [handoff.fallback, '', handoff.url],
557
+ continueLabel: 'Continue',
558
+ next: 'chat-menu'
559
+ });
560
+ setScreen('result');
561
+ },
562
+ onBack: () => setScreen('general-menu')
563
+ });
564
+ }
565
+
566
+ if (screen === 'inspect') {
567
+ return h(OptionScreen, {
568
+ title: 'Inspect',
569
+ lines: ['Browse workspace status and coverage.'],
570
+ options: [
571
+ { label: 'Workspace status', value: 'status' },
572
+ { label: 'Teams', value: 'teams' },
573
+ { label: 'Schedules', value: 'schedules' },
574
+ { label: 'Escalation policies', value: 'policies' },
575
+ { label: 'Back', value: 'back' }
576
+ ],
577
+ onSelect: (option) => {
578
+ if (option.value === 'back') setScreen('general-menu');
579
+ else setScreen(option.value);
580
+ },
581
+ onBack: () => setScreen('general-menu')
582
+ });
583
+ }
584
+
585
+ if (screen === 'setup-menu') {
586
+ return h(OptionScreen, {
587
+ title: 'Setup',
588
+ lines: ['Choose the setup action you want to run.'],
589
+ options: [
590
+ { label: 'Create a team', value: 'create-team' },
591
+ { label: 'Add team members', value: 'add-members-picker' },
592
+ { label: 'Create a schedule', value: 'create-schedule' },
593
+ { label: 'Create an escalation policy', value: 'create-escalation' },
594
+ { label: 'Create a status page', value: 'create-status-page' },
595
+ { label: 'Back', value: 'back' }
596
+ ],
597
+ onSelect: (option) => {
598
+ if (option.value === 'back') {
599
+ setScreen('general-menu');
600
+ return;
601
+ }
602
+ if (option.value === 'create-team') {
603
+ setScreen('create-team');
604
+ return;
605
+ }
606
+ if (option.value === 'create-status-page') {
607
+ // Launch the guided public status-page flow (detects existing pages first).
608
+ setSpExisting(null);
609
+ setFormState({ sp: { isPublic: true, returnTo: 'general-menu' } });
610
+ setScreen('sp-start');
611
+ return;
612
+ }
613
+ setFormState({ pendingAction: option.value === 'create-schedule' ? 'create-schedule-name' : option.value });
614
+ setScreen('team-picker');
615
+ },
616
+ onBack: () => setScreen('general-menu')
617
+ });
618
+ }
619
+
620
+ if (screen === 'verify-menu') {
621
+ return h(OptionScreen, {
622
+ title: 'Verify',
623
+ lines: ['Choose the verification flow you want to run.'],
624
+ options: [
625
+ { label: 'Send a test alert', value: 'create-test-alert' },
626
+ { label: 'Create a test incident', value: 'create-test-incident' },
627
+ { label: 'Back', value: 'back' }
628
+ ],
629
+ onSelect: (option) => {
630
+ if (option.value === 'back') {
631
+ setScreen('general-menu');
632
+ return;
633
+ }
634
+ setFormState({ pendingAction: option.value });
635
+ setScreen('team-picker');
636
+ },
637
+ onBack: () => setScreen('general-menu')
638
+ });
639
+ }
640
+
641
+ if (screen === 'integrations-menu') {
642
+ return h(OptionScreen, {
643
+ title: 'Integrations',
644
+ lines: ['Choose the integration flow you want to run.'],
645
+ options: [
646
+ { label: 'Connect Slack for incidents', value: 'slack' },
647
+ { label: 'Connect an alert source', value: 'alert-source' },
648
+ { label: 'Connect vendor integration in Rootly web', value: 'vendor' },
649
+ { label: 'Back', value: 'back' }
650
+ ],
651
+ onSelect: async (option) => {
652
+ if (option.value === 'back') {
653
+ setScreen('general-menu');
654
+ return;
655
+ }
656
+ if (option.value === 'alert-source') {
657
+ setFormState({ pendingAction: 'create-alert-source' });
658
+ setScreen('team-picker');
659
+ return;
660
+ }
661
+ if (option.value === 'slack') {
662
+ setLoading(true);
663
+ const result = await startWebHandoffForTui({ kind: 'Slack', open: true });
664
+ setLoading(false);
665
+ setResultScreen({
666
+ title: result.ok ? 'Slack handoff ready' : 'Slack handoff failed',
667
+ lines: result.ok
668
+ ? [
669
+ 'Slack still uses the Rootly web flow.',
670
+ '',
671
+ result.data?.url ? hyperlink(result.data.url, '↗ Open Slack setup') : 'Open the Slack setup page in Rootly.',
672
+ '',
673
+ 'Finish connecting Slack in your browser, then choose Continue to refresh your workspace status.'
674
+ ]
675
+ : [friendlyError(result.summary)],
676
+ next: 'menu'
677
+ });
678
+ setScreen('result');
679
+ return;
680
+ }
681
+ setScreen('vendor-menu');
682
+ },
683
+ onBack: () => setScreen('general-menu')
684
+ });
685
+ }
686
+
687
+ if (screen === 'vendor-menu') {
688
+ return h(OptionScreen, {
689
+ title: 'Vendor integration',
690
+ lines: ['These integrations still connect through Rootly web.'],
691
+ options: [
692
+ { label: 'Datadog', value: 'Datadog' },
693
+ { label: 'Grafana', value: 'Grafana' },
694
+ { label: 'PagerDuty', value: 'PagerDuty' },
695
+ { label: 'Opsgenie', value: 'Opsgenie' },
696
+ { label: 'Sentry', value: 'Sentry' },
697
+ { label: 'Back', value: 'back' }
698
+ ],
699
+ onSelect: async (option) => {
700
+ if (option.value === 'back') {
701
+ setScreen('integrations-menu');
702
+ return;
703
+ }
704
+ setLoading(true);
705
+ const result = await startWebHandoffForTui({ kind: option.value, open: true });
706
+ setLoading(false);
707
+ setResultScreen({
708
+ title: result.ok ? 'Vendor handoff ready' : 'Vendor handoff failed',
709
+ lines: result.ok
710
+ ? [
711
+ `Opened ${option.value} in Rootly web.`,
712
+ ...(result.data?.url ? ['', hyperlink(result.data.url, `↗ Open ${option.value} setup`)] : []),
713
+ '',
714
+ `Finish connecting ${option.value} in your browser, then choose Continue to refresh your workspace status.`
715
+ ]
716
+ : [friendlyError(result.summary)],
717
+ next: 'menu'
718
+ });
719
+ setScreen('result');
720
+ },
721
+ onBack: () => setScreen('integrations-menu')
722
+ });
723
+ }
724
+
725
+ if (screen === 'mcp-menu') {
726
+ return h(OptionScreen, {
727
+ title: 'MCP / IDE setup',
728
+ lines: ['Configure the hosted Rootly MCP server for supported clients.'],
729
+ options: [
730
+ { label: 'Preview Codex config', value: 'preview-codex' },
731
+ { label: 'Apply Codex config', value: 'apply-codex' },
732
+ { label: 'Back', value: 'back' }
733
+ ],
734
+ onSelect: async (option) => {
735
+ if (option.value === 'back') {
736
+ setScreen('menu');
737
+ return;
738
+ }
739
+ setLoading(true);
740
+ if (option.value === 'preview-codex') {
741
+ const result = await previewMcpForTui({ clients: ['Codex'], auth: 'Use stored token' });
742
+ setLoading(false);
743
+ setMcpPreview(result);
744
+ setScreen('mcp-preview');
745
+ return;
746
+ }
747
+ const result = await applyMcpForTui({ clients: ['Codex'], auth: 'Use stored token' });
748
+ setLoading(false);
749
+ setResultScreen({
750
+ title: result.ok ? 'MCP configured' : 'MCP setup blocked',
751
+ lines: result.ok
752
+ ? (result.data?.results || []).map((entry) => `${entry.client}: ${entry.targetPath}`)
753
+ : [friendlyError(result.summary)],
754
+ next: 'menu'
755
+ });
756
+ setScreen('result');
757
+ },
758
+ onBack: () => setScreen('menu')
759
+ });
760
+ }
761
+
762
+ if (screen === 'mcp-preview') {
763
+ return h(ListScreen, {
764
+ title: 'MCP preview',
765
+ items: (mcpPreview?.data?.preview || []).map((entry) => entry.replace(/\n/g, ' ')),
766
+ emptyLabel: 'No preview available.',
767
+ onBack: () => setScreen('mcp-menu')
768
+ });
769
+ }
770
+
771
+ if (screen === 'teams') {
772
+ return h(ListScreen, {
773
+ title: 'Teams',
774
+ items: (teamsData?.teams || []).map((team) => `${team.name} · ${team.memberCount || 0} members · ${team.scheduleCount || 0} schedules · ${team.escalationPolicyCount || 0} escalations`),
775
+ emptyLabel: 'No teams found.',
776
+ onBack: () => setScreen('inspect')
777
+ });
778
+ }
779
+
780
+ if (screen === 'schedules') {
781
+ return h(ListScreen, {
782
+ title: 'Schedules',
783
+ items: (schedulesData?.schedules || []).map((schedule) => {
784
+ const attrs = schedule.attributes || {};
785
+ const owners = Array.isArray(attrs.owner_group_ids) && attrs.owner_group_ids.length > 0
786
+ ? `${attrs.owner_group_ids.length} teams`
787
+ : 'no teams';
788
+ return `${attrs.name || schedule.id} · ${attrs.all_time_coverage ? '24/7' : 'custom coverage'} · ${owners}`;
789
+ }),
790
+ emptyLabel: 'No schedules found.',
791
+ onBack: () => setScreen('inspect')
792
+ });
793
+ }
794
+
795
+ if (screen === 'policies') {
796
+ return h(ListScreen, {
797
+ title: 'Escalation policies',
798
+ items: (policiesData?.policies || []).map((policy) => {
799
+ const attrs = policy.attributes || {};
800
+ const teamCount = Array.isArray(attrs.group_ids) ? attrs.group_ids.length : 0;
801
+ return `${attrs.name || policy.id} · ${teamCount} teams · repeats ${attrs.repeat_count ?? 0}`;
802
+ }),
803
+ emptyLabel: 'No escalation policies found.',
804
+ onBack: () => setScreen('inspect')
805
+ });
806
+ }
807
+
808
+ if (screen === 'setup-complete') {
809
+ return h(OptionScreen, {
810
+ context: 'all set',
811
+ header: h(
812
+ Box,
813
+ { flexDirection: 'column', alignItems: 'center', marginBottom: 1 },
814
+ h(Celebration),
815
+ h(BigText, { text: 'INCIDENT-READY', color: palette.brand })
816
+ ),
817
+ lines: [
818
+ 'Your core Rootly setup is in place — teams, on-call, escalation, and alerting.',
819
+ '',
820
+ 'Your account comes with a public status page — set it up to keep customers informed.'
821
+ ],
822
+ options: [
823
+ { label: 'Set up your public status page', value: 'status-page' },
824
+ { label: 'Continue configuring the platform', value: 'configure' },
825
+ { label: 'Talk to our founder JJ (seriously)', value: 'chat-ceo' },
826
+ { label: 'Book a demo with sales', value: 'book-demo' },
827
+ { label: 'Back to menu', value: 'back' }
828
+ ],
829
+ onSelect: async (option) => {
830
+ if (option.value === 'status-page') {
831
+ // Go straight to the seeded public page (no picker, no create branch).
832
+ setSpExisting(null);
833
+ setFormState({ sp: { isPublic: true, directPublic: true, returnTo: 'setup-complete' } });
834
+ setScreen('sp-start');
835
+ return;
836
+ }
837
+ if (option.value === 'configure') {
838
+ setScreen('general-menu');
839
+ return;
840
+ }
841
+ if (option.value === 'back') {
842
+ setScreen('menu');
843
+ return;
844
+ }
845
+ const handoff = option.value === 'book-demo'
846
+ ? {
847
+ url: SALES_DEMO_URL,
848
+ title: 'Book a demo with sales',
849
+ opened: 'Opened the Rootly demo booking page in your browser.',
850
+ fallback: 'Open this link to book a demo with sales:'
851
+ }
852
+ : {
853
+ url: CEO_CAL_URL,
854
+ title: 'Talk to our founder JJ',
855
+ opened: "Opened JJ's calendar in your browser.",
856
+ fallback: 'Open this link to book a time with JJ:'
857
+ };
858
+ setLoading(true);
859
+ const result = await openExternalUrlForTui(handoff.url);
860
+ setLoading(false);
861
+ setResultScreen({
862
+ title: handoff.title,
863
+ lines: result.opened
864
+ ? [handoff.opened]
865
+ : [handoff.fallback, '', handoff.url],
866
+ continueLabel: 'Continue',
867
+ next: 'setup-complete'
868
+ });
869
+ setScreen('result');
870
+ },
871
+ onBack: () => setScreen('menu')
872
+ });
873
+ }
874
+
875
+ if (screen === 'one-shot') {
876
+ return h(OptionScreen, {
877
+ title: 'Quick start',
878
+ lines: [
879
+ 'Set up everything at once: a team, an on-call schedule, an escalation policy, and an alert source, then fire a test alert and open a test incident so you can see the full flow.',
880
+ '',
881
+ 'Next you’ll pick who goes on the team and on call. Anything that already exists is reused.'
882
+ ],
883
+ options: [
884
+ { label: 'Continue', value: 'continue' },
885
+ { label: 'Back to menu', value: 'back' }
886
+ ],
887
+ onSelect: (option) => {
888
+ setScreen(option.value === 'continue' ? 'one-shot-prereqs' : 'menu');
889
+ },
890
+ onBack: () => setScreen('menu')
891
+ });
892
+ }
893
+
894
+ if (screen === 'one-shot-prereqs') {
895
+ // Wait for the phone lookup before rendering, so the screen doesn't flash
896
+ // the "no phone" layout and then flip to "phone on file" when it resolves.
897
+ if (userPhone === null) {
898
+ return h(LoadingScreen, {
899
+ title: 'Getting things ready…',
900
+ detail: 'Checking how you’ll be notified.'
901
+ });
902
+ }
903
+
904
+ const openHandoff = async (kind, title, where) => {
905
+ setLoading(true);
906
+ const result = await startWebHandoffForTui({ kind, open: true });
907
+ setLoading(false);
908
+ const url = result?.data?.url;
909
+ setResultScreen({
910
+ title,
911
+ lines: result?.data?.opened
912
+ ? [`Opened ${where} in your browser.`, '', 'We’ll be here when you’re done.', ...(url ? ['', url] : [])]
913
+ : ['Open this link to finish, then continue here:', ...(url ? ['', url] : [])],
914
+ next: 'one-shot-prereqs'
915
+ });
916
+ setScreen('result');
917
+ };
918
+
919
+ const hasPhone = Boolean(userPhone?.hasPhone);
920
+ const lines = hasPhone
921
+ ? [
922
+ 'A test alert pages whoever is on call.',
923
+ '',
924
+ { text: `✓ Phone on file — ${formatPhone(userPhone?.phone) || 'added'}`, color: palette.success, bold: true },
925
+ '',
926
+ 'Connect Slack too if you like, then continue.'
927
+ ]
928
+ : [
929
+ 'A test alert and incident only reach you if you have somewhere to be notified.',
930
+ '',
931
+ 'Connect Slack and/or add a phone number, then continue. You can also skip and do this later.'
932
+ ];
933
+
934
+ return h(OptionScreen, {
935
+ title: 'Before we set up',
936
+ lines,
937
+ // With a phone on file, Continue is the default (first). Without one, lead
938
+ // with adding a number — otherwise the test alert can't page anyone.
939
+ options: hasPhone
940
+ ? [
941
+ { label: 'Continue setup', value: 'continue' },
942
+ { label: 'Connect Slack', value: 'slack' },
943
+ { label: 'Back to menu', value: 'back' }
944
+ ]
945
+ : [
946
+ { label: 'Add a phone number (recommended — so the test alert pages you)', value: 'phone' },
947
+ { label: 'Continue setup', value: 'continue' },
948
+ { label: 'Connect Slack', value: 'slack' },
949
+ { label: 'Back to menu', value: 'back' }
950
+ ],
951
+ onSelect: async (option) => {
952
+ if (option.value === 'slack') {
953
+ await openHandoff('Slack', 'Connect Slack', 'Slack setup');
954
+ return;
955
+ }
956
+ if (option.value === 'phone') {
957
+ setScreen('phone-entry');
958
+ return;
959
+ }
960
+ if (option.value === 'continue') {
961
+ // No phone on file → the test alert may not reach anyone. Confirm
962
+ // before running so the demo doesn't silently page no one.
963
+ setScreen(hasPhone ? 'one-shot-members' : 'one-shot-no-notify');
964
+ return;
965
+ }
966
+ setScreen('menu');
967
+ },
968
+ onBack: () => setScreen('menu')
969
+ });
970
+ }
971
+
972
+ if (screen === 'one-shot-no-notify') {
973
+ return h(OptionScreen, {
974
+ title: 'No phone number yet',
975
+ lines: [
976
+ 'You don’t have a phone number on file.',
977
+ '',
978
+ 'If you connected Slack, you’re all set — the test alert will reach you there. Otherwise it won’t page anyone.',
979
+ '',
980
+ 'Add a number, or continue anyway.'
981
+ ],
982
+ options: [
983
+ { label: 'Add a phone number', value: 'phone' },
984
+ { label: 'Continue anyway', value: 'continue' },
985
+ { label: 'Back', value: 'back' }
986
+ ],
987
+ onSelect: (option) => {
988
+ if (option.value === 'phone') setScreen('phone-entry');
989
+ else if (option.value === 'continue') setScreen('one-shot-members');
990
+ else setScreen('one-shot-prereqs');
991
+ },
992
+ onBack: () => setScreen('one-shot-prereqs')
993
+ });
994
+ }
995
+
996
+ if (screen === 'phone-entry') {
997
+ return h(TextEntryScreen, {
998
+ title: 'Add a phone number',
999
+ prompt: 'Enter your mobile number. We’ll text you a code to verify it.',
1000
+ lines: ['Outside the US or Canada? Start with your country code, like +44.'],
1001
+ placeholder: '(415) 555-0123',
1002
+ onSubmit: async (value) => {
1003
+ setLoading(true);
1004
+ const result = await startPhoneVerificationForTui({ phone: value });
1005
+ setLoading(false);
1006
+ if (result.ok) {
1007
+ setFormState((prev) => ({ ...prev, phoneNumberId: result.data.phoneNumberId, phone: result.data.phone }));
1008
+ setScreen('phone-code');
1009
+ return;
1010
+ }
1011
+ setResultScreen({ title: 'Add a phone number', lines: [friendlyError(result.summary)], next: 'phone-entry', continueLabel: 'Try again' });
1012
+ setScreen('result');
1013
+ },
1014
+ onBack: () => setScreen('one-shot-prereqs')
1015
+ });
1016
+ }
1017
+
1018
+ if (screen === 'phone-code') {
1019
+ return h(TextEntryScreen, {
1020
+ title: 'Verify phone number',
1021
+ prompt: `Enter the 6-digit code we texted to ${formState.phone || 'your phone'}.`,
1022
+ lines: ['It can take a few seconds to arrive.'],
1023
+ placeholder: '123456',
1024
+ onSubmit: async (value) => {
1025
+ setLoading(true);
1026
+ const result = await confirmPhoneVerificationForTui({ phoneNumberId: formState.phoneNumberId, code: value });
1027
+ setLoading(false);
1028
+ if (result.ok) {
1029
+ setUserPhone(null); // refresh so the pre-flight shows the new number
1030
+ setResultScreen({
1031
+ title: 'Phone verified',
1032
+ lines: ['Your phone number is verified and ready for paging.'],
1033
+ next: 'one-shot-prereqs',
1034
+ continueLabel: 'Done'
1035
+ });
1036
+ setScreen('result');
1037
+ return;
1038
+ }
1039
+ setResultScreen({
1040
+ title: 'Verification failed',
1041
+ lines: [friendlyError(result.summary), 'Check the code and try again.'],
1042
+ next: 'phone-code',
1043
+ continueLabel: 'Try again'
1044
+ });
1045
+ setScreen('result');
1046
+ },
1047
+ onBack: () => setScreen('one-shot-prereqs')
1048
+ });
1049
+ }
1050
+
1051
+ if (screen === 'one-shot-members') {
1052
+ const options = (directoryUsers?.users || []).map((user) => ({
1053
+ label: user.name ? `${user.name}${user.email ? ` — ${user.email}` : ''}` : (user.email || user.id),
1054
+ value: user.id
1055
+ }));
1056
+
1057
+ // With no directory (an OAuth session can't list /v1/users) or only the
1058
+ // signed-in user available, a check/uncheck list of one is pointless — just
1059
+ // confirm and add that user (the chain puts them on call). Show the
1060
+ // multiselect only when there are several people to choose from.
1061
+ if (options.length <= 1) {
1062
+ const onlyUser = options[0];
1063
+ return h(OptionScreen, {
1064
+ title: 'Quick start',
1065
+ lines: [
1066
+ onlyUser
1067
+ ? 'You’ll be added to the team and put on call. (Sign in with an API key to add more teammates.)'
1068
+ : directoryUsers?.userLookupUnavailable
1069
+ ? 'This sign-in can’t list other Rootly users, so just you will be added to the team and put on call.'
1070
+ : 'No other users to add — just you will be added to the team and put on call.'
1071
+ ],
1072
+ options: [
1073
+ { label: 'Run setup', value: 'run' },
1074
+ { label: 'Back to menu', value: 'back' }
1075
+ ],
1076
+ onSelect: (option) => {
1077
+ if (option.value === 'back') {
1078
+ setScreen('menu');
1079
+ return;
1080
+ }
1081
+ setFormState({ oneShotMemberIds: onlyUser ? [onlyUser.value] : [] });
1082
+ setScreen('one-shot-running');
1083
+ },
1084
+ onBack: () => setScreen('menu')
1085
+ });
1086
+ }
1087
+
1088
+ return h(MultiSelectScreen, {
1089
+ title: 'Who goes on the team & on call?',
1090
+ options,
1091
+ onSubmit: (selectedOptions) => {
1092
+ setFormState({ oneShotMemberIds: selectedOptions.map((option) => option.value) });
1093
+ setScreen('one-shot-running');
1094
+ },
1095
+ onBack: () => setScreen('menu')
1096
+ });
1097
+ }
1098
+
1099
+ if (screen === 'one-shot-running') {
1100
+ const usersById = {};
1101
+ (directoryUsers?.users || []).forEach((user) => {
1102
+ usersById[user.id] = user;
1103
+ });
1104
+ return h(OneShotRunnerScreen, {
1105
+ memberIds: formState.oneShotMemberIds || [],
1106
+ usersById,
1107
+ runner: runOneShotSetupForTui,
1108
+ // A successful run lands on the incident-ready screen (with the CEO nudge).
1109
+ onContinue: () => {
1110
+ clearWorkspaceCache();
1111
+ setScreen('setup-complete');
1112
+ },
1113
+ onMenu: () => {
1114
+ clearWorkspaceCache();
1115
+ setScreen('menu');
1116
+ },
1117
+ onRetry: () => {
1118
+ clearWorkspaceCache();
1119
+ setScreen('one-shot-members');
1120
+ },
1121
+ onExit: leave
1122
+ });
1123
+ }
1124
+
1125
+ if (screen === 'placeholder') {
1126
+ return h(OptionScreen, {
1127
+ title: 'Nothing to do here',
1128
+ lines: [
1129
+ 'There is no recommended action to run right now.',
1130
+ '',
1131
+ 'Use the menu to inspect your workspace, run setup, or connect integrations.'
1132
+ ],
1133
+ options: [
1134
+ { label: 'Back to main menu', value: 'back' }
1135
+ ],
1136
+ onSelect: () => setScreen('menu'),
1137
+ onBack: () => setScreen('menu')
1138
+ });
1139
+ }
1140
+
1141
+ if (screen === 'exit-confirm') {
1142
+ return h(OptionScreen, {
1143
+ title: 'Exit',
1144
+ lines: ['Keep your saved sign-in for next time?'],
1145
+ // Default (first / preselected) option keeps the sign-in.
1146
+ options: [
1147
+ { label: 'Just exit', value: 'keep' },
1148
+ { label: 'Delete from keychain', value: 'delete' }
1149
+ ],
1150
+ onSelect: async (option) => {
1151
+ if (option.value === 'delete') {
1152
+ setLoading(true);
1153
+ await deleteTokenForTui();
1154
+ setLoading(false);
1155
+ }
1156
+ leave();
1157
+ },
1158
+ onBack: () => setScreen('menu')
1159
+ });
1160
+ }
1161
+
1162
+ if (screen === 'create-team') {
1163
+ return h(TextEntryScreen, {
1164
+ title: 'Create a team',
1165
+ prompt: 'Enter the team name you want to create.',
1166
+ placeholder: 'Team name',
1167
+ initialValue: 'Incident Response',
1168
+ onSubmit: async (value) => {
1169
+ setLoading(true);
1170
+ const result = await createTeamForTui({ name: value });
1171
+ setLoading(false);
1172
+ setResultScreen({
1173
+ title: result.ok ? 'Team created' : 'Team setup needs attention',
1174
+ lines: result.ok
1175
+ ? [`Team: ${result.data?.name || value}`, result.data?.id ? `Team ID: ${result.data.id}` : 'Team created']
1176
+ : [friendlyError(result.summary)],
1177
+ next: result.ok ? 'menu' : 'create-team'
1178
+ });
1179
+ setScreen('result');
1180
+ },
1181
+ onBack: () => setScreen('menu')
1182
+ });
1183
+ }
1184
+
1185
+ // ---- Guided status page flow (public): detect → name → auth → components → customize → publish ----
1186
+ const sp = formState.sp || {};
1187
+ const patchSp = (patch) => setFormState((prev) => ({ ...prev, sp: { ...(prev.sp || {}), ...patch } }));
1188
+ const spReturn = sp.returnTo || 'menu';
1189
+
1190
+ // The live public page lives at /teams/<org-slug>/status-pages/<slug>/public, but
1191
+ // the org slug isn't exposed in the API — so we open the account editor instead
1192
+ // (slug-keyed, always works; it previews the live page and links to it with the
1193
+ // correct URL Rootly builds server-side). Both helpers point there.
1194
+ const spViewUrl = (slug) => `${STATUS_PAGES_URL}/${slug}/edit`;
1195
+ const spManageUrl = (slug) => `${STATUS_PAGES_URL}/${slug}/edit`;
1196
+
1197
+ // Create-or-update the page (always published), then open it in the browser so
1198
+ // there's no URL to copy. Further tweaks (incl. disabling) happen in the web UI.
1199
+ const finalizeStatusPage = async () => {
1200
+ const fields = {
1201
+ title: sp.title,
1202
+ authenticationMethod: sp.authMethod || 'none',
1203
+ authenticationPassword: sp.authPassword || null,
1204
+ publish: true,
1205
+ // Only set components when the user actually chose some this session, so
1206
+ // editing a page without touching components doesn't wipe its existing set.
1207
+ ...(sp.componentsTouched
1208
+ ? { serviceIds: sp.serviceIds || [], functionalityIds: sp.functionalityIds || [] }
1209
+ : {})
1210
+ };
1211
+ setLoading(true);
1212
+ const result = sp.editId
1213
+ ? await updateStatusPageForTui({ id: sp.editId, ...fields })
1214
+ : await createStatusPageForTui({ isPublic: sp.isPublic !== false, ...fields });
1215
+ setLoading(false);
1216
+ const slug = result.data?.slug || sp.existingPage?.slug;
1217
+ const isPublic = result.data?.public ?? (sp.isPublic !== false);
1218
+ const liveUrl = slug ? spViewUrl(slug, isPublic) : STATUS_PAGES_URL;
1219
+ if (result.ok) await openExternalUrlForTui(liveUrl);
1220
+ setResultScreen({
1221
+ title: result.ok ? 'Your status page is live' : 'Status page needs attention',
1222
+ lines: result.ok
1223
+ ? [
1224
+ `${result.data?.title || sp.title}`,
1225
+ '',
1226
+ 'Opened your status page in Rootly — preview it and customize it there.'
1227
+ ]
1228
+ : [friendlyError(result.summary)],
1229
+ actions: result.ok
1230
+ ? [{ label: 'Open the status page again', onSelect: () => openExternalUrlForTui(liveUrl) }]
1231
+ : [],
1232
+ continueLabel: result.ok ? 'Continue' : undefined,
1233
+ next: result.ok ? spReturn : 'sp-components'
1234
+ });
1235
+ setScreen('result');
1236
+ };
1237
+
1238
+ if (screen === 'sp-start') {
1239
+ // While loading, or when jumping straight to the public page, show the loader
1240
+ // (the directPublic effect navigates away once pages are loaded).
1241
+ if (spExisting === null || sp.directPublic) {
1242
+ return h(LoadingScreen, { title: 'Status page', detail: 'Opening your public status page…' });
1243
+ }
1244
+ const existing = spExisting.pages || [];
1245
+ if (!existing.length) {
1246
+ return h(OptionScreen, {
1247
+ title: 'Create a status page',
1248
+ lines: ['No status page exists yet — let’s create one.'],
1249
+ options: [{ label: 'Create a status page', value: 'new' }, { label: 'Back', value: 'back' }],
1250
+ onSelect: (option) => setScreen(option.value === 'back' ? spReturn : 'sp-name'),
1251
+ onBack: () => setScreen(spReturn)
1252
+ });
1253
+ }
1254
+ return h(OptionScreen, {
1255
+ title: 'Status pages',
1256
+ lines: [
1257
+ `Your workspace already has ${existing.length} status page${existing.length === 1 ? '' : 's'} (new accounts come with a default one).`,
1258
+ '',
1259
+ 'Customize an existing page, or create a new one.'
1260
+ ],
1261
+ options: [
1262
+ { label: 'Customize an existing page', value: 'existing' },
1263
+ { label: 'Create a new page', value: 'new' },
1264
+ { label: 'Back', value: 'back' }
1265
+ ],
1266
+ onSelect: (option) => {
1267
+ if (option.value === 'back') { setScreen(spReturn); return; }
1268
+ setScreen(option.value === 'existing' ? 'sp-pick-existing' : 'sp-name');
1269
+ },
1270
+ onBack: () => setScreen(spReturn)
1271
+ });
1272
+ }
1273
+
1274
+ if (screen === 'sp-pick-existing') {
1275
+ const existing = spExisting?.pages || [];
1276
+ return h(OptionScreen, {
1277
+ title: 'Pick a page to customize',
1278
+ lines: ['Choose the status page to update.'],
1279
+ options: [
1280
+ ...existing.map((p) => ({
1281
+ label: `${p.title} · ${p.public ? 'public' : 'internal'} · ${p.published ? 'published' : 'draft'}`,
1282
+ value: p.id
1283
+ })),
1284
+ { label: 'Back', value: 'back' }
1285
+ ],
1286
+ onSelect: (option) => {
1287
+ if (option.value === 'back') { setScreen('sp-start'); return; }
1288
+ patchSp({ existingPage: existing.find((p) => p.id === option.value) || null });
1289
+ setScreen('sp-existing-actions');
1290
+ },
1291
+ onBack: () => setScreen('sp-start')
1292
+ });
1293
+ }
1294
+
1295
+ if (screen === 'sp-existing-actions') {
1296
+ const page = sp.existingPage || {};
1297
+ const slug = page.slug;
1298
+ const liveUrl = slug ? spViewUrl(slug, page.public) : STATUS_PAGES_URL;
1299
+ const manageUrl = slug ? spManageUrl(slug) : STATUS_PAGES_URL;
1300
+ return h(OptionScreen, {
1301
+ title: `Customize ${page.title || 'status page'}`,
1302
+ lines: [
1303
+ `${page.public ? 'Public' : 'Internal'} · ${page.published ? 'published' : 'draft'}.`,
1304
+ '',
1305
+ page.published
1306
+ ? 'It’s live. Edit its settings here, or open the full editor in Rootly.'
1307
+ : 'Publish makes it live. Edit its settings here, or open the full editor in Rootly.'
1308
+ ],
1309
+ options: [
1310
+ { label: 'Edit settings', value: 'edit' },
1311
+ ...(page.published ? [] : [{ label: 'Publish it now', value: 'publish' }]),
1312
+ { label: 'Open it in Rootly (full editor)', value: 'open' },
1313
+ { label: 'Back', value: 'back' }
1314
+ ],
1315
+ onSelect: async (option) => {
1316
+ if (option.value === 'back') { setScreen(sp.directPublic ? spReturn : 'sp-pick-existing'); return; }
1317
+ if (option.value === 'edit') { setScreen('sp-edit-menu'); return; }
1318
+ if (option.value === 'open') {
1319
+ setResultScreen({
1320
+ title: page.title || 'Status page',
1321
+ lines: [hyperlink(page.published ? liveUrl : manageUrl, '↗ Open it in Rootly'), '', 'Customize and publish it in the web app.'],
1322
+ next: spReturn
1323
+ });
1324
+ setScreen('result');
1325
+ return;
1326
+ }
1327
+ setLoading(true);
1328
+ const result = await publishStatusPageForTui({ id: page.id });
1329
+ setLoading(false);
1330
+ setResultScreen({
1331
+ title: result.ok ? 'Status page published' : 'Could not publish',
1332
+ lines: result.ok
1333
+ ? [`${page.title} is now live.`, '', hyperlink(liveUrl, '↗ View your status page'), '', 'Customize it anytime in the web app.']
1334
+ : [friendlyError(result.summary)],
1335
+ next: result.ok ? spReturn : 'sp-existing-actions'
1336
+ });
1337
+ setScreen('result');
1338
+ },
1339
+ onBack: () => setScreen(sp.directPublic ? spReturn : 'sp-pick-existing')
1340
+ });
1341
+ }
1342
+
1343
+ if (screen === 'sp-edit-menu') {
1344
+ const page = sp.existingPage || {};
1345
+ const slug = page.slug;
1346
+ const liveUrl = slug ? spViewUrl(slug, page.public) : STATUS_PAGES_URL;
1347
+ const manageUrl = slug ? spManageUrl(slug) : STATUS_PAGES_URL;
1348
+ const editError = (result) => {
1349
+ setResultScreen({ title: 'Update failed', lines: [friendlyError(result.summary)], next: 'sp-edit-menu' });
1350
+ setScreen('result');
1351
+ };
1352
+ return h(OptionScreen, {
1353
+ title: `Edit ${page.title || 'status page'}`,
1354
+ lines: [`${page.public ? 'Public' : 'Internal'} · ${page.published ? 'published' : 'draft'}.`, '', 'Change one setting at a time — each is saved immediately.'],
1355
+ options: [
1356
+ { label: 'Rename', value: 'name' },
1357
+ { label: 'Authentication', value: 'auth' },
1358
+ { label: 'Components to show', value: 'components' },
1359
+ { label: 'Website link', value: 'website' },
1360
+ { label: page.published ? 'Unpublish (make it a draft)' : 'Publish now', value: 'toggle' },
1361
+ { label: 'Done', value: 'done' }
1362
+ ],
1363
+ onSelect: async (option) => {
1364
+ if (option.value === 'done') {
1365
+ setResultScreen({
1366
+ title: 'Status page updated',
1367
+ lines: [
1368
+ `${page.title}`,
1369
+ `${page.public ? 'Public' : 'Internal'} · ${page.published ? 'published' : 'draft'}`,
1370
+ '',
1371
+ hyperlink(page.published ? liveUrl : manageUrl, page.published ? '↗ View your status page' : '↗ Open it in Rootly')
1372
+ ],
1373
+ next: spReturn
1374
+ });
1375
+ setScreen('result');
1376
+ return;
1377
+ }
1378
+ if (option.value === 'toggle') {
1379
+ setLoading(true);
1380
+ const result = await updateStatusPageForTui({ id: page.id, publish: !page.published });
1381
+ setLoading(false);
1382
+ if (result.ok) patchSp({ existingPage: { ...page, published: result.data.published, slug: result.data.slug || page.slug } });
1383
+ else editError(result);
1384
+ return;
1385
+ }
1386
+ setScreen(`sp-edit-${option.value}`);
1387
+ },
1388
+ onBack: () => setScreen(sp.directPublic ? spReturn : 'sp-existing-actions')
1389
+ });
1390
+ }
1391
+
1392
+ if (screen === 'sp-edit-name') {
1393
+ const page = sp.existingPage || {};
1394
+ return h(TextEntryScreen, {
1395
+ title: 'Rename status page',
1396
+ prompt: 'New name for the page.',
1397
+ initialValue: page.title || '',
1398
+ onSubmit: async (value) => {
1399
+ setLoading(true);
1400
+ const result = await updateStatusPageForTui({ id: page.id, title: value });
1401
+ setLoading(false);
1402
+ if (result.ok) patchSp({ existingPage: { ...page, title: result.data.title || value, slug: result.data.slug || page.slug } });
1403
+ setScreen('sp-edit-menu');
1404
+ },
1405
+ onBack: () => setScreen('sp-edit-menu')
1406
+ });
1407
+ }
1408
+
1409
+ if (screen === 'sp-edit-auth') {
1410
+ const page = sp.existingPage || {};
1411
+ return h(OptionScreen, {
1412
+ title: 'Page authentication',
1413
+ lines: ['Who can view the page?'],
1414
+ options: [
1415
+ { label: 'No authentication', value: 'none' },
1416
+ { label: 'Password protect', value: 'password' },
1417
+ { label: 'Back', value: 'back' }
1418
+ ],
1419
+ onSelect: async (option) => {
1420
+ if (option.value === 'back') { setScreen('sp-edit-menu'); return; }
1421
+ if (option.value === 'password') { setScreen('sp-edit-password'); return; }
1422
+ setLoading(true);
1423
+ await updateStatusPageForTui({ id: page.id, authenticationMethod: 'none' });
1424
+ setLoading(false);
1425
+ setScreen('sp-edit-menu');
1426
+ },
1427
+ onBack: () => setScreen('sp-edit-menu')
1428
+ });
1429
+ }
1430
+
1431
+ if (screen === 'sp-edit-password') {
1432
+ const page = sp.existingPage || {};
1433
+ return h(TextEntryScreen, {
1434
+ title: 'Set a page password',
1435
+ prompt: 'Visitors will need this password to view the page.',
1436
+ placeholder: 'Password',
1437
+ hidden: true,
1438
+ onSubmit: async (value) => {
1439
+ setLoading(true);
1440
+ await updateStatusPageForTui({ id: page.id, authenticationMethod: 'password', authenticationPassword: value });
1441
+ setLoading(false);
1442
+ setScreen('sp-edit-menu');
1443
+ },
1444
+ onBack: () => setScreen('sp-edit-auth')
1445
+ });
1446
+ }
1447
+
1448
+ if (screen === 'sp-edit-components') {
1449
+ const page = sp.existingPage || {};
1450
+ const options = spComponents?.components || [];
1451
+ if (!options.length) {
1452
+ return h(OptionScreen, {
1453
+ title: 'Components to show',
1454
+ lines: ['No services or functionalities exist yet to show on the page.', '', 'Add some in the Rootly web app, then come back.'],
1455
+ options: [{ label: 'Back', value: 'back' }],
1456
+ onSelect: () => setScreen('sp-edit-menu'),
1457
+ onBack: () => setScreen('sp-edit-menu')
1458
+ });
1459
+ }
1460
+ return h(MultiSelectScreen, {
1461
+ title: 'Components to show on the page',
1462
+ options,
1463
+ onSubmit: async (selected) => {
1464
+ setLoading(true);
1465
+ await updateStatusPageForTui({
1466
+ id: page.id,
1467
+ serviceIds: selected.filter((o) => o.value.startsWith('service:')).map((o) => o.value.slice(8)),
1468
+ functionalityIds: selected.filter((o) => o.value.startsWith('functionality:')).map((o) => o.value.slice(14))
1469
+ });
1470
+ setLoading(false);
1471
+ setScreen('sp-edit-menu');
1472
+ },
1473
+ onBack: () => setScreen('sp-edit-menu')
1474
+ });
1475
+ }
1476
+
1477
+ if (screen === 'sp-edit-website') {
1478
+ const page = sp.existingPage || {};
1479
+ return h(TextEntryScreen, {
1480
+ title: 'Website link',
1481
+ prompt: 'Link back to your main site (leave blank to clear it).',
1482
+ placeholder: 'https://yourcompany.com',
1483
+ allowEmpty: true,
1484
+ onSubmit: async (value) => {
1485
+ setLoading(true);
1486
+ await updateStatusPageForTui({ id: page.id, websiteUrl: value });
1487
+ setLoading(false);
1488
+ setScreen('sp-edit-menu');
1489
+ },
1490
+ onBack: () => setScreen('sp-edit-menu')
1491
+ });
1492
+ }
1493
+
1494
+ if (screen === 'sp-name') {
1495
+ const orgName = state?.teams?.workspace?.name;
1496
+ const defaultName = orgName ? `${orgName} Status` : 'Status Page';
1497
+ return h(TextEntryScreen, {
1498
+ title: sp.editId ? 'Set up your public status page' : 'Create a status page',
1499
+ prompt: sp.editId
1500
+ ? 'Confirm or change the name of your status page.'
1501
+ : `Name your ${sp.isPublic === false ? 'internal' : 'public'} status page.`,
1502
+ placeholder: 'e.g. Acme Status',
1503
+ initialValue: sp.title || defaultName,
1504
+ onSubmit: (value) => {
1505
+ patchSp({ title: value });
1506
+ setScreen('sp-auth');
1507
+ },
1508
+ onBack: () => setScreen(sp.directPublic ? spReturn : 'sp-start')
1509
+ });
1510
+ }
1511
+
1512
+ if (screen === 'sp-auth') {
1513
+ return h(OptionScreen, {
1514
+ title: 'Page authentication',
1515
+ lines: ['Who can view the page?', '', 'Default is open — anyone with the link.'],
1516
+ options: [
1517
+ { label: 'No authentication (default)', value: 'none' },
1518
+ { label: 'Password protect', value: 'password' },
1519
+ { label: 'Back', value: 'back' }
1520
+ ],
1521
+ onSelect: (option) => {
1522
+ if (option.value === 'back') { setScreen('sp-name'); return; }
1523
+ patchSp({ authMethod: option.value });
1524
+ setScreen(option.value === 'password' ? 'sp-password' : 'sp-components');
1525
+ },
1526
+ onBack: () => setScreen('sp-name')
1527
+ });
1528
+ }
1529
+
1530
+ if (screen === 'sp-password') {
1531
+ return h(TextEntryScreen, {
1532
+ title: 'Set a page password',
1533
+ prompt: 'Visitors will need this password to view the page.',
1534
+ placeholder: 'Password',
1535
+ hidden: true,
1536
+ onSubmit: (value) => {
1537
+ patchSp({ authPassword: value });
1538
+ setScreen('sp-components');
1539
+ },
1540
+ onBack: () => setScreen('sp-auth')
1541
+ });
1542
+ }
1543
+
1544
+ if (screen === 'sp-components') {
1545
+ // Map selected ids back to labels so we can show what's been added, each
1546
+ // with a green check.
1547
+ const componentLabel = new Map((spComponents?.components || []).map((c) => [c.value, c.label]));
1548
+ const addedValues = [
1549
+ ...(sp.serviceIds || []).map((id) => `service:${id}`),
1550
+ ...(sp.functionalityIds || []).map((id) => `functionality:${id}`)
1551
+ ];
1552
+ const count = addedValues.length;
1553
+ const lines = count
1554
+ ? [
1555
+ 'Added components:',
1556
+ '',
1557
+ ...addedValues.map((value) => ({ text: `✓ ${componentLabel.get(value) || value}`, color: palette.success })),
1558
+ '',
1559
+ 'Add more, or publish your page.'
1560
+ ]
1561
+ : ['Components let visitors see the status of your services.', '', 'Add some now, or just publish — you can add more later.'];
1562
+ return h(OptionScreen, {
1563
+ title: 'Components to show on the page',
1564
+ lines,
1565
+ options: [
1566
+ { label: 'Go to publish', value: 'publish' },
1567
+ { label: count ? 'Add or remove components' : 'Add a component', value: 'add' },
1568
+ { label: 'Create your own component', value: 'custom' },
1569
+ { label: 'Back', value: 'back' }
1570
+ ],
1571
+ onSelect: async (option) => {
1572
+ if (option.value === 'back') { setScreen('sp-auth'); return; }
1573
+ if (option.value === 'add') { setScreen('sp-components-pick'); return; }
1574
+ if (option.value === 'custom') { setScreen('sp-component-custom'); return; }
1575
+ await finalizeStatusPage();
1576
+ },
1577
+ onBack: () => setScreen('sp-auth')
1578
+ });
1579
+ }
1580
+
1581
+ if (screen === 'sp-component-custom') {
1582
+ return h(TextEntryScreen, {
1583
+ title: 'Create your own component',
1584
+ prompt: 'Name a component to show on your status page (e.g. API, Dashboard, Checkout).',
1585
+ placeholder: 'e.g. API',
1586
+ onSubmit: async (value) => {
1587
+ const name = String(value || '').trim();
1588
+ if (!name) { setScreen('sp-components'); return; }
1589
+ setLoading(true);
1590
+ const result = await createCustomComponentForTui({ name });
1591
+ setLoading(false);
1592
+ if (result.ok) {
1593
+ // The custom component is a Service — add its id to serviceIds and to
1594
+ // the component list so it shows alongside the existing components.
1595
+ patchSp({ serviceIds: [...(sp.serviceIds || []), result.data.id], componentsTouched: true });
1596
+ setSpComponents((prev) => ({ components: [...(prev?.components || []), result.data.component] }));
1597
+ setScreen('sp-components');
1598
+ return;
1599
+ }
1600
+ setResultScreen({
1601
+ title: 'Component needs attention',
1602
+ lines: [friendlyError(result.summary)],
1603
+ next: 'sp-components'
1604
+ });
1605
+ setScreen('result');
1606
+ },
1607
+ onBack: () => setScreen('sp-components')
1608
+ });
1609
+ }
1610
+
1611
+ if (screen === 'sp-components-pick') {
1612
+ const options = spComponents?.components || [];
1613
+ if (!options.length) {
1614
+ return h(OptionScreen, {
1615
+ title: 'Add a component',
1616
+ lines: ['No services or functionalities exist yet to show on the page.', '', 'You can add components later in the Rootly web app.'],
1617
+ options: [{ label: 'Back', value: 'back' }],
1618
+ onSelect: () => setScreen('sp-components'),
1619
+ onBack: () => setScreen('sp-components')
1620
+ });
1621
+ }
1622
+ return h(MultiSelectScreen, {
1623
+ title: 'Toggle components to show on the page (checked = added)',
1624
+ options,
1625
+ // Pre-check what's already on the page so the user can uncheck to remove
1626
+ // and check to add — submitting applies the full set.
1627
+ initialSelectedValues: [
1628
+ ...(sp.serviceIds || []).map((id) => `service:${id}`),
1629
+ ...(sp.functionalityIds || []).map((id) => `functionality:${id}`)
1630
+ ],
1631
+ onSubmit: (selected) => {
1632
+ patchSp({
1633
+ serviceIds: selected.filter((o) => o.value.startsWith('service:')).map((o) => o.value.slice(8)),
1634
+ functionalityIds: selected.filter((o) => o.value.startsWith('functionality:')).map((o) => o.value.slice(14)),
1635
+ componentsTouched: true
1636
+ });
1637
+ setScreen('sp-components');
1638
+ },
1639
+ onBack: () => setScreen('sp-components')
1640
+ });
1641
+ }
1642
+
1643
+ if (screen === 'team-picker') {
1644
+ return h(OptionScreen, {
1645
+ title: 'Select a team',
1646
+ lines: ['Choose the team for the next step.'],
1647
+ options: [
1648
+ ...((state?.teams?.all || []).map((team) => ({
1649
+ label: `${team.name} (${team.memberCount || 0} members, ${team.scheduleCount || 0} schedules, ${team.escalationPolicyCount || 0} escalations)`,
1650
+ value: team.id
1651
+ }))),
1652
+ { label: 'Back', value: 'back' }
1653
+ ],
1654
+ onSelect: (option) => {
1655
+ if (option.value === 'back') {
1656
+ setScreen('menu');
1657
+ return;
1658
+ }
1659
+ const team = (state?.teams?.all || []).find((entry) => entry.id === option.value) || null;
1660
+ setSelectedTeam(team);
1661
+ const next = formState.pendingAction;
1662
+ setScreen(next || 'menu');
1663
+ },
1664
+ onBack: () => setScreen('menu')
1665
+ });
1666
+ }
1667
+
1668
+ if (screen === 'add-members-picker') {
1669
+ const options = (addableUsers?.addable || []).map((user) => ({
1670
+ label: user.name || user.email || user.id,
1671
+ value: user.id
1672
+ }));
1673
+
1674
+ if (!options.length) {
1675
+ const lookupUnavailable = addableUsers?.userLookupUnavailable;
1676
+ return h(OptionScreen, {
1677
+ title: 'Add team members',
1678
+ lines: lookupUnavailable
1679
+ ? [
1680
+ 'This sign-in can’t list Rootly users (the user directory needs an API key).',
1681
+ 'Invite someone by email instead.'
1682
+ ]
1683
+ : [
1684
+ selectedTeam
1685
+ ? `Everyone in this workspace is already on ${selectedTeam.name}, or there are no other users to add.`
1686
+ : 'No users available to add.',
1687
+ 'You can still invite someone by email.'
1688
+ ],
1689
+ options: [
1690
+ { label: 'Invite by email instead', value: 'email' },
1691
+ { label: 'Back', value: 'back' }
1692
+ ],
1693
+ onSelect: (option) => {
1694
+ if (option.value === 'email') setScreen('add-team-members');
1695
+ else setScreen('general-menu');
1696
+ },
1697
+ onBack: () => setScreen('general-menu')
1698
+ });
1699
+ }
1700
+
1701
+ // Only one user available (e.g. an OAuth session that can only see the
1702
+ // signed-in user) — confirm-and-add rather than a 1-item check/uncheck.
1703
+ if (options.length === 1) {
1704
+ const onlyUser = options[0];
1705
+ return h(OptionScreen, {
1706
+ title: selectedTeam ? `Add members to ${selectedTeam.name}` : 'Add team members',
1707
+ lines: [`${onlyUser.label} is the only user available to add.`],
1708
+ options: [
1709
+ { label: `Add ${onlyUser.label}`, value: 'add' },
1710
+ { label: 'Invite someone else by email', value: 'email' },
1711
+ { label: 'Back', value: 'back' }
1712
+ ],
1713
+ onSelect: async (option) => {
1714
+ if (option.value === 'back') { setScreen('general-menu'); return; }
1715
+ if (option.value === 'email') { setScreen('add-team-members'); return; }
1716
+ setLoading(true);
1717
+ const result = await addTeamMembersByIdsForTui({ teamId: selectedTeam?.id, userIds: [onlyUser.value] });
1718
+ setLoading(false);
1719
+ setResultScreen({
1720
+ title: result.ok ? 'Team members added' : 'Team members need attention',
1721
+ lines: result.ok
1722
+ ? [`Team: ${selectedTeam?.name || 'Unknown'}`, `Added as members: ${onlyUser.label}`]
1723
+ : [friendlyError(result.summary)],
1724
+ next: 'menu'
1725
+ });
1726
+ setScreen('result');
1727
+ },
1728
+ onBack: () => setScreen('general-menu')
1729
+ });
1730
+ }
1731
+
1732
+ return h(MultiSelectScreen, {
1733
+ title: selectedTeam ? `Add members to ${selectedTeam.name}` : 'Add team members',
1734
+ options,
1735
+ onSubmit: async (selectedOptions) => {
1736
+ if (!selectedOptions.length) {
1737
+ setScreen('menu');
1738
+ return;
1739
+ }
1740
+ setLoading(true);
1741
+ const result = await addTeamMembersByIdsForTui({
1742
+ teamId: selectedTeam?.id,
1743
+ userIds: selectedOptions.map((option) => option.value)
1744
+ });
1745
+ setLoading(false);
1746
+ setResultScreen({
1747
+ title: result.ok ? 'Team members added' : 'Team members need attention',
1748
+ lines: result.ok
1749
+ ? [
1750
+ `Team: ${selectedTeam?.name || 'Unknown'}`,
1751
+ `Added as members: ${selectedOptions.map((option) => option.label).join(', ')}`
1752
+ ]
1753
+ : [friendlyError(result.summary)],
1754
+ next: 'menu'
1755
+ });
1756
+ setScreen('result');
1757
+ },
1758
+ onBack: () => setScreen('general-menu')
1759
+ });
1760
+ }
1761
+
1762
+ if (screen === 'add-team-members') {
1763
+ return h(TextEntryScreen, {
1764
+ title: 'Add team members (by email)',
1765
+ prompt: selectedTeam ? `Comma-separated emails for ${selectedTeam.name}` : 'Comma-separated emails',
1766
+ placeholder: 'alice@example.com, bob@example.com',
1767
+ onSubmit: async (value) => {
1768
+ setLoading(true);
1769
+ const emails = value.split(',').map((entry) => entry.trim()).filter(Boolean);
1770
+ const result = await addTeamMembersForTui({ teamId: selectedTeam?.id, emails });
1771
+ setLoading(false);
1772
+ const memberNames = (result.data?.matchedUsers || [])
1773
+ .map((user) => user.name || user.email)
1774
+ .filter(Boolean)
1775
+ .join(', ');
1776
+ setResultScreen({
1777
+ title: result.ok ? 'Team members updated' : 'Team members need attention',
1778
+ lines: result.ok
1779
+ ? [
1780
+ `Team: ${selectedTeam?.name || 'Unknown'}`,
1781
+ result.data?.userLookupUnavailable
1782
+ ? 'Could not resolve Rootly users; emails attached as team contacts.'
1783
+ : `Added as members: ${memberNames || 'no new members'}`,
1784
+ ...(result.data?.unresolvedEmails?.length
1785
+ ? [`Not in Rootly yet (added as contacts): ${result.data.unresolvedEmails.join(', ')}`]
1786
+ : [])
1787
+ ]
1788
+ : [friendlyError(result.summary)],
1789
+ next: 'menu'
1790
+ });
1791
+ setScreen('result');
1792
+ },
1793
+ onBack: () => setScreen('menu')
1794
+ });
1795
+ }
1796
+
1797
+ if (screen === 'create-schedule-name') {
1798
+ return h(TextEntryScreen, {
1799
+ title: 'Create a schedule',
1800
+ prompt: selectedTeam ? `Schedule name for ${selectedTeam.name}` : 'Schedule name',
1801
+ initialValue: selectedTeam ? `${selectedTeam.name} On-Call` : '',
1802
+ onSubmit: (value) => {
1803
+ setFormState((current) => ({ ...current, scheduleName: value || `${selectedTeam?.name || 'Team'} On-Call` }));
1804
+ setScreen('create-schedule-handoff');
1805
+ },
1806
+ onBack: () => setScreen('menu')
1807
+ });
1808
+ }
1809
+
1810
+ if (screen === 'create-schedule-handoff') {
1811
+ return h(TextEntryScreen, {
1812
+ title: 'Daily handoff time',
1813
+ prompt: 'HH:MM (workspace timezone)',
1814
+ initialValue: '09:00',
1815
+ onSubmit: (value) => {
1816
+ setFormState((current) => ({ ...current, handoffTime: value || '09:00' }));
1817
+ setScreen('schedule-members');
1818
+ },
1819
+ onBack: () => setScreen('create-schedule-name')
1820
+ });
1821
+ }
1822
+
1823
+ if (screen === 'schedule-members') {
1824
+ const memberOptions = (teamMembersData?.members || [])
1825
+ .filter((member) => !member.serviceAccount)
1826
+ .map((member) => ({
1827
+ label: member.name || member.email || member.id,
1828
+ value: member.id
1829
+ }));
1830
+
1831
+ if (!memberOptions.length) {
1832
+ return h(OptionScreen, {
1833
+ title: 'On-call rotation',
1834
+ lines: [
1835
+ selectedTeam ? `${selectedTeam.name} has no members to staff a rotation yet.` : 'This team has no members to staff a rotation yet.',
1836
+ 'The schedule will be created without a staffed rotation.'
1837
+ ],
1838
+ options: [
1839
+ { label: 'Create schedule anyway', value: 'continue' },
1840
+ { label: 'Back', value: 'back' }
1841
+ ],
1842
+ onSelect: async (option) => {
1843
+ if (option.value === 'back') {
1844
+ setScreen('create-schedule-handoff');
1845
+ return;
1846
+ }
1847
+ setLoading(true);
1848
+ const result = await createScheduleForTui({
1849
+ teamId: selectedTeam?.id,
1850
+ name: formState.scheduleName,
1851
+ handoffTime: formState.handoffTime || '09:00',
1852
+ memberIds: []
1853
+ });
1854
+ setLoading(false);
1855
+ setResultScreen({
1856
+ title: result.ok ? 'Schedule created' : 'Schedule setup needs attention',
1857
+ lines: result.ok
1858
+ ? [`Team: ${selectedTeam?.name || 'Unknown'}`, `Schedule: ${formState.scheduleName}`, 'No one is on the initial rotation yet.']
1859
+ : [friendlyError(result.summary)],
1860
+ next: 'menu'
1861
+ });
1862
+ setScreen('result');
1863
+ },
1864
+ onBack: () => setScreen('create-schedule-handoff')
1865
+ });
1866
+ }
1867
+
1868
+ return h(MultiSelectScreen, {
1869
+ title: 'Who should be on the on-call rotation?',
1870
+ options: memberOptions,
1871
+ onSubmit: async (selectedOptions) => {
1872
+ setLoading(true);
1873
+ const result = await createScheduleForTui({
1874
+ teamId: selectedTeam?.id,
1875
+ name: formState.scheduleName,
1876
+ handoffTime: formState.handoffTime || '09:00',
1877
+ memberIds: selectedOptions.map((option) => option.value)
1878
+ });
1879
+ setLoading(false);
1880
+ setResultScreen({
1881
+ title: result.ok ? 'Schedule created' : 'Schedule setup needs attention',
1882
+ lines: result.ok
1883
+ ? [
1884
+ `Team: ${selectedTeam?.name || 'Unknown'}`,
1885
+ `Schedule: ${formState.scheduleName}`,
1886
+ `Rotation members: ${selectedOptions.map((option) => option.label).join(', ') || 'none'}`
1887
+ ]
1888
+ : [friendlyError(result.summary)],
1889
+ next: 'menu'
1890
+ });
1891
+ setScreen('result');
1892
+ },
1893
+ onBack: () => setScreen('create-schedule-handoff')
1894
+ });
1895
+ }
1896
+
1897
+ if (screen === 'create-escalation') {
1898
+ return h(TextEntryScreen, {
1899
+ title: 'Create an escalation policy',
1900
+ prompt: selectedTeam ? `Policy name for ${selectedTeam.name}` : 'Policy name',
1901
+ initialValue: selectedTeam ? `${selectedTeam.name} Default Escalation` : '',
1902
+ onSubmit: async (value) => {
1903
+ setLoading(true);
1904
+ const result = await createEscalationPolicyForTui({
1905
+ teamId: selectedTeam?.id,
1906
+ name: value || `${selectedTeam?.name || 'Team'} Default Escalation`,
1907
+ repeatCount: 1
1908
+ });
1909
+ setLoading(false);
1910
+ setResultScreen({
1911
+ title: result.ok ? 'Escalation policy created' : 'Escalation policy needs attention',
1912
+ lines: result.ok
1913
+ ? [`Team: ${selectedTeam?.name || 'Unknown'}`, `Policy: ${result.data?.name || value}`]
1914
+ : [friendlyError(result.summary)],
1915
+ next: 'menu'
1916
+ });
1917
+ setScreen('result');
1918
+ },
1919
+ onBack: () => setScreen('menu')
1920
+ });
1921
+ }
1922
+
1923
+ if (screen === 'create-alert-source') {
1924
+ return h(TextEntryScreen, {
1925
+ title: 'Connect an alert source',
1926
+ prompt: selectedTeam ? `Alert source name for ${selectedTeam.name}` : 'Alert source name',
1927
+ initialValue: selectedTeam ? `${selectedTeam.name} Generic Webhook` : 'Generic webhook',
1928
+ onSubmit: async (value) => {
1929
+ setLoading(true);
1930
+ const result = await createAlertSourceForTui({
1931
+ teamId: selectedTeam?.id,
1932
+ name: value || 'Generic webhook'
1933
+ });
1934
+ setLoading(false);
1935
+ setResultScreen({
1936
+ title: result.ok ? 'Alert source created' : 'Alert source needs attention',
1937
+ lines: result.ok
1938
+ ? [`Source: ${result.data?.name || value}`, result.data?.webhookEndpoint ? `Webhook: ${result.data.webhookEndpoint}` : 'Webhook created']
1939
+ : [friendlyError(result.summary)],
1940
+ next: 'menu'
1941
+ });
1942
+ setScreen('result');
1943
+ },
1944
+ onBack: () => setScreen('menu')
1945
+ });
1946
+ }
1947
+
1948
+ if (screen === 'create-test-alert') {
1949
+ return h(TextEntryScreen, {
1950
+ title: 'Send a test alert',
1951
+ prompt: selectedTeam ? `Alert summary for ${selectedTeam.name}` : 'Alert summary',
1952
+ initialValue: 'Rootly Wizard test alert',
1953
+ onSubmit: async (value) => {
1954
+ setLoading(true);
1955
+ const result = await createTestAlertForTui({
1956
+ summary: value || 'Rootly Wizard test alert',
1957
+ groupIds: selectedTeam?.id ? [selectedTeam.id] : [],
1958
+ // Page the team's on-call escalation policy so the alert actually rings.
1959
+ page: true
1960
+ });
1961
+ setLoading(false);
1962
+ setResultScreen({
1963
+ title: result.ok ? 'Test alert sent' : 'Test alert needs attention',
1964
+ lines: result.ok
1965
+ ? [
1966
+ `Alert: ${result.data?.summary || value}`,
1967
+ result.data?.paged
1968
+ ? 'Paging the on-call person now — watch for a call or text.'
1969
+ : 'Heads up: no escalation policy found for this team, so it won’t page anyone.'
1970
+ ]
1971
+ : [friendlyError(result.summary)],
1972
+ next: 'menu'
1973
+ });
1974
+ setScreen('result');
1975
+ },
1976
+ onBack: () => setScreen('menu')
1977
+ });
1978
+ }
1979
+
1980
+ if (screen === 'create-test-incident') {
1981
+ return h(TextEntryScreen, {
1982
+ title: 'Create a test incident',
1983
+ prompt: selectedTeam ? `Incident title for ${selectedTeam.name}` : 'Incident title',
1984
+ initialValue: 'Rootly Wizard test incident',
1985
+ onSubmit: async (value) => {
1986
+ setLoading(true);
1987
+ const result = await createTestIncidentForTui({
1988
+ title: value || 'Rootly Wizard test incident',
1989
+ groupIds: selectedTeam?.id ? [selectedTeam.id] : []
1990
+ });
1991
+ setLoading(false);
1992
+ setResultScreen({
1993
+ title: result.ok ? 'Test incident created' : 'Test incident needs attention',
1994
+ lines: result.ok ? [`Incident: ${result.data?.title || value}`] : [friendlyError(result.summary)],
1995
+ next: 'menu'
1996
+ });
1997
+ setScreen('result');
1998
+ },
1999
+ onBack: () => setScreen('menu')
2000
+ });
2001
+ }
2002
+
2003
+ return h(MainMenuScreen, {
2004
+ state,
2005
+ onBack: (option) => {
2006
+ if (!option) {
2007
+ leave();
2008
+ return;
2009
+ }
2010
+ if (option.value === 'back') {
2011
+ leave();
2012
+ return;
2013
+ }
2014
+ if (option.value === 'exit') {
2015
+ leave();
2016
+ return;
2017
+ }
2018
+ if (option.value === 'status') {
2019
+ setScreen('status');
2020
+ return;
2021
+ }
2022
+ if (option.value === 'auth') {
2023
+ setScreen('auth-method');
2024
+ return;
2025
+ }
2026
+ if (option.value === 'inspect') {
2027
+ setScreen('inspect');
2028
+ return;
2029
+ }
2030
+ if (option.value === 'quickstart') {
2031
+ setScreen('one-shot');
2032
+ return;
2033
+ }
2034
+ if (option.value === 'create-team') {
2035
+ setScreen('create-team');
2036
+ return;
2037
+ }
2038
+ if (option.value === 'setup') {
2039
+ setScreen('setup-menu');
2040
+ return;
2041
+ }
2042
+ if (option.value === 'general') {
2043
+ setScreen('general-menu');
2044
+ return;
2045
+ }
2046
+ if (option.value === 'integrations') {
2047
+ setScreen('integrations-menu');
2048
+ return;
2049
+ }
2050
+ if (option.value === 'verify') {
2051
+ setScreen('verify-menu');
2052
+ return;
2053
+ }
2054
+ if (option.value === 'mcp') {
2055
+ setScreen('mcp-menu');
2056
+ return;
2057
+ }
2058
+ setScreen('placeholder');
2059
+ },
2060
+ onExit: () => setScreen('exit-confirm')
2061
+ });
2062
+ }
2063
+
2064
+ export async function startInteractiveWizard() {
2065
+ let closed = false;
2066
+ enterAltScreen();
2067
+ const app = render(h(InkWizardApp, { onExit: () => { closed = true; } }), {
2068
+ stdout: process.stdout,
2069
+ stdin: process.stdin,
2070
+ exitOnCtrlC: true
2071
+ });
2072
+ try {
2073
+ await app.waitUntilExit();
2074
+ return { closed };
2075
+ } finally {
2076
+ leaveAltScreen();
2077
+ }
2078
+ }