@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,574 @@
1
+ import { extractTeamId, extractUserId, isServiceAccount, loadApiClient } from '../runtime.js';
2
+
3
+ function formatError(error) {
4
+ return error?.message?.replace(/^Rootly API request failed for [^:]+:\s*/, '') || 'unknown error';
5
+ }
6
+
7
+ function isUsersLookupUnavailable(error) {
8
+ const message = error?.message || '';
9
+ return message.includes('/v1/users') && message.includes('404');
10
+ }
11
+
12
+ export async function createTeamAction({
13
+ name,
14
+ description = 'Created during Rootly setup',
15
+ memberEmails = [],
16
+ enableAlertsAndBroadcast = false
17
+ } = {}) {
18
+ const api = await loadApiClient();
19
+
20
+ // Seed the signed-in human as the team's first member + admin. A user-less
21
+ // API key authenticates as a service account, which we never seed.
22
+ const currentUser = await api.getCurrentUser();
23
+ const currentUserId = extractUserId(currentUser);
24
+ const selfSeed = currentUserId && !isServiceAccount(currentUser) ? [String(currentUserId)] : [];
25
+
26
+ const cleanEmails = (memberEmails || []).map((value) => String(value).trim()).filter(Boolean);
27
+ const matchedUsers = [];
28
+ let userLookupUnavailable = false;
29
+ for (const email of cleanEmails) {
30
+ try {
31
+ const match = await api.findUserByEmail(email);
32
+ if (match) {
33
+ matchedUsers.push(match);
34
+ }
35
+ } catch (error) {
36
+ if (isUsersLookupUnavailable(error)) {
37
+ userLookupUnavailable = true;
38
+ break;
39
+ }
40
+
41
+ throw error;
42
+ }
43
+ }
44
+ const memberIds = matchedUsers.map((user) => Number(user.id)).filter((id) => Number.isFinite(id));
45
+
46
+ const attributes = {
47
+ name,
48
+ description,
49
+ user_ids: [...selfSeed, ...memberIds],
50
+ admin_ids: [...selfSeed],
51
+ auto_add_members_when_attached: true
52
+ };
53
+
54
+ if (cleanEmails.length) {
55
+ attributes.notify_emails = cleanEmails;
56
+ }
57
+
58
+ if (enableAlertsAndBroadcast) {
59
+ attributes.alerts_email_enabled = true;
60
+ attributes.incident_broadcast_enabled = true;
61
+ }
62
+
63
+ // OAuth sessions can't assign user_ids/admin_ids — Team/Membership/User are
64
+ // read-only under OAuth scopes, so a create that seeds members is denied. Fall
65
+ // back to a bare create so the team still gets made; schedule ownership and the
66
+ // on-call rotation reference the user directly, so paging still works.
67
+ let payload;
68
+ let membershipSkipped = false;
69
+ try {
70
+ payload = await api.createTeam(attributes);
71
+ } catch (error) {
72
+ const denied = /\b40[134]\b|not found or unauthorized/i.test(error?.message || '');
73
+ if (denied && (attributes.user_ids.length || attributes.admin_ids.length)) {
74
+ payload = await api.createTeam({ name, description });
75
+ membershipSkipped = true;
76
+ } else {
77
+ throw error;
78
+ }
79
+ }
80
+ const teamId = extractTeamId(payload);
81
+
82
+ return {
83
+ ok: true,
84
+ summary: membershipSkipped
85
+ ? `Created team ${name} (this sign-in can't add members; add them in the Rootly web app).`
86
+ : `Created team ${name}.`,
87
+ data: {
88
+ id: teamId,
89
+ name,
90
+ description,
91
+ memberIds: membershipSkipped ? [] : memberIds,
92
+ membershipSkipped,
93
+ userLookupUnavailable,
94
+ matchedUsers: matchedUsers.map((user) => ({
95
+ id: user.id,
96
+ email: user.attributes?.email || null,
97
+ name: user.attributes?.full_name || user.attributes?.name || null
98
+ }))
99
+ }
100
+ };
101
+ }
102
+
103
+ export async function addTeamMembersAction({ teamId, emails }) {
104
+ const api = await loadApiClient();
105
+
106
+ // Current membership lives on the team's attributes.user_ids (integers).
107
+ const teamPayload = await api.getTeam(teamId);
108
+ const teamAttributes = teamPayload?.data?.attributes || {};
109
+ const existingUserIds = (Array.isArray(teamAttributes.user_ids) ? teamAttributes.user_ids : [])
110
+ .map((id) => String(id))
111
+ .filter(Boolean);
112
+ const existingNotifyEmails = Array.isArray(teamAttributes.notify_emails) ? teamAttributes.notify_emails : [];
113
+
114
+ const cleanEmails = (emails || []).map((value) => value.trim()).filter(Boolean);
115
+ const resolvedMembers = [];
116
+ const unresolvedEmails = [];
117
+ let userLookupUnavailable = false;
118
+ for (const email of cleanEmails) {
119
+ try {
120
+ const match = await api.findUserByEmail(email);
121
+ if (match) {
122
+ resolvedMembers.push(match);
123
+ } else {
124
+ unresolvedEmails.push(email);
125
+ }
126
+ } catch (error) {
127
+ if (isUsersLookupUnavailable(error)) {
128
+ userLookupUnavailable = true;
129
+ break;
130
+ }
131
+
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ const resolvedMemberIds = resolvedMembers.map((user) => String(user.id)).filter(Boolean);
137
+
138
+ const attributes = {};
139
+ if (userLookupUnavailable) {
140
+ // No user lookup in this session — fall back to attaching every email as a contact.
141
+ attributes.notify_emails = [...new Set([...existingNotifyEmails, ...cleanEmails])];
142
+ } else {
143
+ // Resolved people become real team members; only unmatched emails stay as contacts.
144
+ attributes.user_ids = [...new Set([...existingUserIds, ...resolvedMemberIds])];
145
+ if (unresolvedEmails.length) {
146
+ attributes.notify_emails = [...new Set([...existingNotifyEmails, ...unresolvedEmails])];
147
+ }
148
+ }
149
+
150
+ // The PUT response echoes attributes.user_ids, so read membership back to confirm.
151
+ const updatePayload = await api.updateTeam(teamId, attributes);
152
+ const updatedAttributes = updatePayload?.data?.attributes || {};
153
+ const memberUserIds = (Array.isArray(updatedAttributes.user_ids) ? updatedAttributes.user_ids : existingUserIds)
154
+ .map((id) => String(id))
155
+ .filter(Boolean);
156
+ const addedMemberIds = resolvedMemberIds.filter((id) => !existingUserIds.includes(id));
157
+
158
+ return {
159
+ ok: true,
160
+ summary: userLookupUnavailable
161
+ ? `Attached ${cleanEmails.length} email(s) to team ${teamId} as contacts (could not resolve Rootly users with this auth session).`
162
+ : `Added ${addedMemberIds.length} member(s) to team ${teamId}.`,
163
+ data: {
164
+ teamId,
165
+ requestedEmails: cleanEmails,
166
+ userLookupUnavailable,
167
+ addedMemberIds,
168
+ memberUserIds,
169
+ unresolvedEmails,
170
+ matchedUsers: resolvedMembers.map((user) => ({
171
+ id: String(user.id),
172
+ email: user.attributes?.email || null,
173
+ name: user.attributes?.full_name || user.attributes?.name || null
174
+ }))
175
+ }
176
+ };
177
+ }
178
+
179
+ export async function addTeamMembersByIdsAction({ teamId, userIds = [] } = {}) {
180
+ const api = await loadApiClient();
181
+
182
+ const teamPayload = await api.getTeam(teamId);
183
+ const existingUserIds = (Array.isArray(teamPayload?.data?.attributes?.user_ids)
184
+ ? teamPayload.data.attributes.user_ids
185
+ : []).map((id) => String(id)).filter(Boolean);
186
+
187
+ const selectedIds = (userIds || []).map((id) => String(id)).filter(Boolean);
188
+ const addedMemberIds = selectedIds.filter((id) => !existingUserIds.includes(id));
189
+ const nextUserIds = [...new Set([...existingUserIds, ...selectedIds])];
190
+
191
+ const updatePayload = await api.updateTeam(teamId, { user_ids: nextUserIds });
192
+ const memberUserIds = (Array.isArray(updatePayload?.data?.attributes?.user_ids)
193
+ ? updatePayload.data.attributes.user_ids
194
+ : nextUserIds).map((id) => String(id)).filter(Boolean);
195
+
196
+ return {
197
+ ok: true,
198
+ summary: `Added ${addedMemberIds.length} member(s) to team ${teamId}.`,
199
+ data: { teamId, addedMemberIds, memberUserIds }
200
+ };
201
+ }
202
+
203
+ export async function createScheduleAction({ teamId, name, handoffTime = '09:00', memberIds = [], reuseByName = false } = {}) {
204
+ const api = await loadApiClient();
205
+
206
+ if (reuseByName) {
207
+ try {
208
+ const existing = findByName((await api.listSchedules())?.data, name, teamId, 'owner_group_ids');
209
+ if (existing) {
210
+ return {
211
+ ok: true,
212
+ summary: `Reused existing schedule ${name}.`,
213
+ data: { teamId, scheduleId: existing.id, name, handoffTime, rotationCreated: false, reused: true }
214
+ };
215
+ }
216
+ } catch {
217
+ // If the lookup fails, fall through and create a fresh one.
218
+ }
219
+ }
220
+
221
+ const currentUser = await api.getCurrentUser();
222
+ const currentUserId = extractUserId(currentUser);
223
+
224
+ const selfId = currentUserId && !isServiceAccount(currentUser) ? currentUserId : null;
225
+
226
+ // Rootly requires a schedule owner (a user-less API key is rejected without
227
+ // one). Prefer a real human — the signed-in user, else the first invited
228
+ // member — and only fall back to the current identity when none exists.
229
+ const ownerUserId = selfId || memberIds[0] || currentUserId;
230
+
231
+ const scheduleAttributes = {
232
+ name,
233
+ description: `Primary schedule for ${teamId}`,
234
+ all_time_coverage: true,
235
+ owner_group_ids: [teamId],
236
+ owner_user_id: ownerUserId
237
+ };
238
+
239
+ const createdSchedule = await api.createSchedule(scheduleAttributes);
240
+ const scheduleId = createdSchedule?.data?.id || createdSchedule?.id || null;
241
+
242
+ // The rotation is exactly the members the caller chose; fall back to the
243
+ // signed-in user only when none were given. The service account is never
244
+ // placed on the rotation, and an empty rotation is skipped entirely.
245
+ const explicitMembers = (memberIds || []).map((id) => String(id)).filter(Boolean);
246
+ const rotationMemberIds = explicitMembers.length
247
+ ? [...new Set(explicitMembers)]
248
+ : (selfId ? [String(selfId)] : []);
249
+ let rotationCreated = false;
250
+ if (scheduleId && rotationMemberIds.length) {
251
+ await api.createScheduleRotation(scheduleId, {
252
+ name: `${name} Primary Rotation`,
253
+ schedule_rotationable_type: 'ScheduleDailyRotation',
254
+ schedule_rotationable_attributes: { handoff_time: handoffTime },
255
+ position: 1,
256
+ active_all_week: true,
257
+ time_zone: 'Etc/UTC',
258
+ schedule_rotation_members: rotationMemberIds.map((id, index) => ({
259
+ member_type: 'User',
260
+ member_id: id,
261
+ position: index + 1
262
+ }))
263
+ });
264
+ rotationCreated = true;
265
+ }
266
+
267
+ return {
268
+ ok: true,
269
+ summary: `Created schedule ${name}.`,
270
+ data: {
271
+ teamId,
272
+ scheduleId,
273
+ name,
274
+ handoffTime,
275
+ rotationCreated
276
+ }
277
+ };
278
+ }
279
+
280
+ // Find an existing resource for this team by name (case-insensitive). Used to
281
+ // keep re-runs idempotent instead of piling up duplicates. `groupKey` names the
282
+ // attribute that holds the owning team ids (varies by resource).
283
+ function findByName(list, name, teamId, groupKey) {
284
+ const target = String(name || '').trim().toLowerCase();
285
+ if (!target) return null;
286
+ return (list || []).find((item) => {
287
+ const attrs = item?.attributes || {};
288
+ if (String(attrs.name || '').trim().toLowerCase() !== target) return false;
289
+ if (!teamId) return true;
290
+ const groups = attrs[groupKey];
291
+ // If the resource exposes its owning teams, require a match; otherwise fall
292
+ // back to a name-only match.
293
+ return !Array.isArray(groups) || groups.map(String).includes(String(teamId));
294
+ }) || null;
295
+ }
296
+
297
+ export async function createEscalationPolicyAction({ teamId, name, repeatCount = 1, createDefaultPath = false, scheduleId = null, reuseByName = false } = {}) {
298
+ const api = await loadApiClient();
299
+
300
+ if (reuseByName) {
301
+ try {
302
+ const existing = findByName((await api.listEscalationPolicies())?.data, name, teamId, 'group_ids');
303
+ if (existing) {
304
+ return {
305
+ ok: true,
306
+ summary: `Reused existing escalation policy ${name}.`,
307
+ data: { id: existing.id, teamId, name, repeatCount, reused: true, pathCreated: false, pathError: null, levelCreated: false, levelError: null }
308
+ };
309
+ }
310
+ } catch {
311
+ // If the lookup fails, fall through and create a fresh one.
312
+ }
313
+ }
314
+
315
+ const payload = await api.createEscalationPolicy({
316
+ name,
317
+ description: `Default escalation policy for ${teamId}`,
318
+ repeat_count: repeatCount,
319
+ group_ids: [teamId],
320
+ service_ids: []
321
+ });
322
+
323
+ const policyId = payload?.data?.id || payload?.id || null;
324
+
325
+ // The default path is best-effort: a path failure must not discard the
326
+ // already-created policy (which would orphan it), so it is caught here.
327
+ let pathCreated = false;
328
+ let pathError = null;
329
+ let pathId = null;
330
+ if (createDefaultPath && policyId) {
331
+ try {
332
+ const pathPayload = await api.createEscalationPath(policyId, {
333
+ name: `${name} Path`,
334
+ notification_type: 'audible',
335
+ path_type: 'escalation',
336
+ default: true,
337
+ match_mode: 'match-all-rules',
338
+ position: 1,
339
+ repeat: false,
340
+ initial_delay: 0,
341
+ rules: []
342
+ });
343
+ pathId = pathPayload?.data?.id || null;
344
+ pathCreated = true;
345
+ } catch (error) {
346
+ pathError = formatError(error);
347
+ }
348
+ }
349
+
350
+ // Add a level that pages the on-call person from the schedule, so a triggered
351
+ // alert actually reaches someone (audible → their call/SMS notification rules).
352
+ let levelCreated = false;
353
+ let levelError = null;
354
+ if (scheduleId && policyId) {
355
+ try {
356
+ await api.createEscalationLevel(policyId, {
357
+ position: 1,
358
+ ...(pathId ? { escalation_policy_path_id: pathId } : {}),
359
+ notification_target_params: [{ id: scheduleId, type: 'schedule' }],
360
+ paging_strategy_configuration_schedule_strategy: 'on_call_only'
361
+ });
362
+ levelCreated = true;
363
+ } catch (error) {
364
+ levelError = formatError(error);
365
+ }
366
+ }
367
+
368
+ return {
369
+ ok: true,
370
+ summary: `Created escalation policy ${name}.`,
371
+ data: {
372
+ id: policyId,
373
+ teamId,
374
+ name,
375
+ repeatCount,
376
+ pathCreated,
377
+ pathError,
378
+ levelCreated,
379
+ levelError
380
+ }
381
+ };
382
+ }
383
+
384
+ export async function createAlertSourceAction({ teamId, name = 'Generic webhook', sourceType = 'generic_webhook', reuseByName = false }) {
385
+ const api = await loadApiClient();
386
+
387
+ if (reuseByName) {
388
+ try {
389
+ const existing = findByName((await api.listAlertSources())?.data, name, teamId, 'owner_group_ids');
390
+ if (existing) {
391
+ const attrs = existing.attributes || {};
392
+ return {
393
+ ok: true,
394
+ summary: `Reused existing alert source ${name}.`,
395
+ data: {
396
+ id: existing.id,
397
+ name,
398
+ teamId: teamId || null,
399
+ reused: true,
400
+ webhookEndpoint: attrs.webhook_endpoint || null,
401
+ secret: attrs.secret || null
402
+ }
403
+ };
404
+ }
405
+ } catch {
406
+ // If the lookup fails, fall through and create a fresh one.
407
+ }
408
+ }
409
+
410
+ const payload = await api.createAlertSource({
411
+ name,
412
+ source_type: sourceType,
413
+ owner_group_ids: teamId ? [teamId] : [],
414
+ sourceable_attributes: {
415
+ auto_resolve: false
416
+ }
417
+ });
418
+
419
+ const attributes = payload?.data?.attributes || {};
420
+
421
+ return {
422
+ ok: true,
423
+ summary: `Created alert source ${name}.`,
424
+ data: {
425
+ id: payload?.data?.id || null,
426
+ name,
427
+ teamId: teamId || null,
428
+ webhookEndpoint: attributes.webhook_endpoint || null,
429
+ secret: attributes.secret || null
430
+ }
431
+ };
432
+ }
433
+
434
+ export async function createStatusPageAction({
435
+ title,
436
+ description,
437
+ isPublic = false,
438
+ // Authentication: 'none' (default) or 'password' (needs authenticationPassword).
439
+ authenticationMethod = 'none',
440
+ authenticationPassword = null,
441
+ // Components to show on the page.
442
+ serviceIds = [],
443
+ functionalityIds = [],
444
+ // publish=true creates it live; publish=false leaves it as an unpublished draft.
445
+ publish = true,
446
+ // Optional "customize further" fields.
447
+ websiteUrl = null
448
+ } = {}) {
449
+ const clean = String(title || '').trim();
450
+ if (!clean) {
451
+ return { ok: false, summary: 'A status page title is required.' };
452
+ }
453
+
454
+ // The description is internal to Rootly — it tells other admins what the page
455
+ // is for. Default to a clear note when the caller doesn't supply one.
456
+ const cleanDescription = String(description ?? '').trim()
457
+ || 'Created by the Rootly setup wizard.';
458
+
459
+ const cleanServiceIds = serviceIds.map((id) => String(id)).filter(Boolean);
460
+ const cleanFunctionalityIds = functionalityIds.map((id) => String(id)).filter(Boolean);
461
+ const usePassword = authenticationMethod === 'password' && authenticationPassword;
462
+
463
+ const api = await loadApiClient();
464
+ const payload = await api.createStatusPage({
465
+ title: clean,
466
+ description: cleanDescription,
467
+ public: Boolean(isPublic),
468
+ enabled: Boolean(publish), // false = unpublished draft
469
+ authentication_method: usePassword ? 'password' : 'none',
470
+ ...(usePassword ? { authentication_password: authenticationPassword } : {}),
471
+ ...(cleanServiceIds.length ? { service_ids: cleanServiceIds } : {}),
472
+ ...(cleanFunctionalityIds.length ? { functionality_ids: cleanFunctionalityIds } : {}),
473
+ ...(websiteUrl ? { website_url: String(websiteUrl).trim() } : {})
474
+ });
475
+ const attributes = payload?.data?.attributes || {};
476
+ const published = Boolean(attributes.enabled);
477
+ const visibility = isPublic ? 'public' : 'internal';
478
+
479
+ return {
480
+ ok: true,
481
+ summary: published
482
+ ? `Published ${visibility} status page ${clean}.`
483
+ : `Saved ${visibility} status page ${clean} as a draft.`,
484
+ data: {
485
+ id: payload?.data?.id || null,
486
+ title: clean,
487
+ public: Boolean(attributes.public),
488
+ published,
489
+ authenticationMethod: usePassword ? 'password' : 'none',
490
+ componentCount: cleanServiceIds.length + cleanFunctionalityIds.length,
491
+ slug: attributes.slug || null
492
+ }
493
+ };
494
+ }
495
+
496
+ export async function getStatusPagesAction() {
497
+ const api = await loadApiClient();
498
+ const payload = await api.listStatusPages();
499
+ const pages = (payload?.data || []).map((p) => ({
500
+ id: p.id,
501
+ title: p.attributes?.title || p.attributes?.public_title || '(untitled)',
502
+ public: Boolean(p.attributes?.public),
503
+ published: Boolean(p.attributes?.enabled),
504
+ slug: p.attributes?.slug || null,
505
+ // Components already attached, so the wizard can show them and not drop
506
+ // them when re-publishing (service_ids/functionality_ids replace the set).
507
+ serviceIds: (p.attributes?.service_ids || []).map((id) => String(id)),
508
+ functionalityIds: (p.attributes?.functionality_ids || []).map((id) => String(id))
509
+ }));
510
+ return { ok: true, data: { pages } };
511
+ }
512
+
513
+ // Partial update — only the keys present in `input` are sent, so editing one
514
+ // field never resets the others.
515
+ export async function updateStatusPageAction(input = {}) {
516
+ const { id } = input;
517
+ if (!id) {
518
+ return { ok: false, summary: 'A status page id is required to update.' };
519
+ }
520
+ const attrs = {};
521
+ if ('title' in input && input.title != null) attrs.title = String(input.title).trim();
522
+ if ('authenticationMethod' in input) {
523
+ const usePassword = input.authenticationMethod === 'password' && input.authenticationPassword;
524
+ attrs.authentication_method = usePassword ? 'password' : 'none';
525
+ if (usePassword) attrs.authentication_password = input.authenticationPassword;
526
+ }
527
+ if ('serviceIds' in input) attrs.service_ids = (input.serviceIds || []).map((x) => String(x));
528
+ if ('functionalityIds' in input) attrs.functionality_ids = (input.functionalityIds || []).map((x) => String(x));
529
+ if ('websiteUrl' in input) attrs.website_url = input.websiteUrl ? String(input.websiteUrl).trim() : '';
530
+ if ('publish' in input) attrs.enabled = Boolean(input.publish);
531
+
532
+ const api = await loadApiClient();
533
+ const payload = await api.updateStatusPage(id, attrs);
534
+ const a = payload?.data?.attributes || {};
535
+ return {
536
+ ok: true,
537
+ summary: 'Status page updated.',
538
+ data: {
539
+ id,
540
+ title: a.title || null,
541
+ public: Boolean(a.public),
542
+ published: Boolean(a.enabled),
543
+ slug: a.slug || null
544
+ }
545
+ };
546
+ }
547
+
548
+ export async function publishStatusPageAction({ id } = {}) {
549
+ if (!id) {
550
+ return { ok: false, summary: 'A status page id is required to publish.' };
551
+ }
552
+ const api = await loadApiClient();
553
+ const payload = await api.updateStatusPage(id, { enabled: true });
554
+ const attributes = payload?.data?.attributes || {};
555
+ return {
556
+ ok: true,
557
+ summary: `Published status page ${attributes.title || id}.`,
558
+ data: {
559
+ id,
560
+ title: attributes.title || null,
561
+ public: Boolean(attributes.public),
562
+ published: Boolean(attributes.enabled),
563
+ slug: attributes.slug || null
564
+ }
565
+ };
566
+ }
567
+
568
+ export function serializeActionError(error, fallbackSummary) {
569
+ return {
570
+ ok: false,
571
+ summary: fallbackSummary,
572
+ error: formatError(error)
573
+ };
574
+ }
@@ -0,0 +1,141 @@
1
+ import { loadApiClient } from '../runtime.js';
2
+
3
+ function cleanIds(values = []) {
4
+ return values.map((value) => String(value).trim()).filter(Boolean);
5
+ }
6
+
7
+ export async function createTestAlertAction({
8
+ summary,
9
+ description = '',
10
+ groupIds = [],
11
+ serviceIds = [],
12
+ environmentIds = [],
13
+ // When a notification target is given (e.g. an escalation policy), the alert
14
+ // is triggered against it so it actually pages the on-call person.
15
+ notificationTargetType = null,
16
+ notificationTargetId = null,
17
+ urgencyId = null,
18
+ source = 'api',
19
+ // When true (and no explicit target is given), resolve the team's escalation
20
+ // policy + High urgency and page it — so a standalone test alert reaches the
21
+ // on-call person, like Quick start does.
22
+ page = false
23
+ } = {}) {
24
+ const api = await loadApiClient();
25
+ const attributes = {
26
+ summary,
27
+ source
28
+ };
29
+
30
+ const cleanedDescription = String(description || '').trim();
31
+ const cleanedGroupIds = cleanIds(groupIds);
32
+ const cleanedServiceIds = cleanIds(serviceIds);
33
+ const cleanedEnvironmentIds = cleanIds(environmentIds);
34
+
35
+ if (page && !notificationTargetType && cleanedGroupIds.length) {
36
+ try {
37
+ const policies = (await api.listEscalationPolicies())?.data || [];
38
+ const teamId = cleanedGroupIds[0];
39
+ const policy = policies.find((p) => {
40
+ const g = p?.attributes?.group_ids;
41
+ return Array.isArray(g) && g.map(String).includes(teamId);
42
+ }) || (policies.length === 1 ? policies[0] : null);
43
+ if (policy) {
44
+ notificationTargetType = 'EscalationPolicy';
45
+ notificationTargetId = policy.id;
46
+ if (!urgencyId) {
47
+ const urgencies = (await api.listAlertUrgencies())?.data || [];
48
+ urgencyId = (urgencies.find((u) => /high/i.test(u?.attributes?.name || '')) || urgencies[0])?.id || null;
49
+ }
50
+ }
51
+ } catch {
52
+ // best-effort; fall back to a passive (non-paging) alert.
53
+ }
54
+ }
55
+
56
+ if (cleanedDescription) {
57
+ attributes.description = cleanedDescription;
58
+ }
59
+
60
+ if (cleanedGroupIds.length) {
61
+ attributes.group_ids = cleanedGroupIds;
62
+ }
63
+
64
+ if (cleanedServiceIds.length) {
65
+ attributes.service_ids = cleanedServiceIds;
66
+ }
67
+
68
+ if (cleanedEnvironmentIds.length) {
69
+ attributes.environment_ids = cleanedEnvironmentIds;
70
+ }
71
+
72
+ // Page a target (escalation policy / schedule / user / group) so the alert
73
+ // escalates to whoever is on call and notifies them.
74
+ const paged = Boolean(notificationTargetType && notificationTargetId);
75
+ if (paged) {
76
+ attributes.notification_target_type = notificationTargetType;
77
+ attributes.notification_target_id = String(notificationTargetId);
78
+ attributes.status = 'triggered';
79
+ }
80
+ if (urgencyId) {
81
+ attributes.alert_urgency_id = String(urgencyId);
82
+ }
83
+
84
+ const payload = await api.createAlert(attributes);
85
+
86
+ return {
87
+ ok: true,
88
+ summary: `Created test alert ${summary}.`,
89
+ data: {
90
+ id: payload?.data?.id || null,
91
+ summary,
92
+ description: cleanedDescription,
93
+ paged,
94
+ notificationTargetType: paged ? notificationTargetType : null
95
+ }
96
+ };
97
+ }
98
+
99
+ export async function createTestIncidentAction({
100
+ title,
101
+ summary = '',
102
+ groupIds = [],
103
+ serviceIds = [],
104
+ environmentIds = [],
105
+ incidentTypeIds = [],
106
+ severityId = null,
107
+ isPrivate = false
108
+ } = {}) {
109
+ const api = await loadApiClient();
110
+
111
+ const attributes = {
112
+ title,
113
+ kind: 'normal',
114
+ private: isPrivate,
115
+ summary,
116
+ group_ids: cleanIds(groupIds),
117
+ service_ids: cleanIds(serviceIds),
118
+ environment_ids: cleanIds(environmentIds),
119
+ incident_type_ids: cleanIds(incidentTypeIds)
120
+ };
121
+
122
+ if (severityId) {
123
+ attributes.severity_id = String(severityId).trim();
124
+ }
125
+
126
+ const payload = await api.createIncident(attributes);
127
+ const responseAttributes = payload?.data?.attributes || {};
128
+
129
+ return {
130
+ ok: true,
131
+ summary: `Created test incident ${title}.`,
132
+ data: {
133
+ id: payload?.data?.id || null,
134
+ title,
135
+ summary,
136
+ slackChannelName: responseAttributes.slack_channel_name || null,
137
+ slackChannelUrl: responseAttributes.slack_channel_url || null,
138
+ slackDeepLink: responseAttributes.slack_channel_deep_link || null
139
+ }
140
+ };
141
+ }