@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rootly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # Rootly Wizard
2
+
3
+ Get your Rootly workspace from empty to incident-ready in minutes — without clicking through every setup screen.
4
+
5
+ Rootly Wizard is a guided command-line setup tool. It walks you through creating a team, putting people on call, wiring up escalation and alerting, creating a status page, and firing a real test alert and incident — including a phone call to whoever is on call — so you can see the whole flow end to end.
6
+
7
+ ![Rootly Wizard welcome screen](assets/welcome-screen.png)
8
+
9
+ ```bash
10
+ npx @rootly/wizard
11
+ ```
12
+
13
+ ## Requirements
14
+
15
+ - **Node.js 18 or newer**
16
+ - A **Rootly account** (don't have one? the wizard can hand you off to sign up)
17
+ - Sign in with a **browser (OAuth)** or a **Rootly API token** (see [Signing in](#signing-in))
18
+ - A terminal (macOS, Linux, or Windows)
19
+
20
+ ## Quick start
21
+
22
+ Run the wizard:
23
+
24
+ ```bash
25
+ npx @rootly/wizard
26
+ ```
27
+
28
+ Sign in (browser or API token — your session is stored securely in your OS keychain), then land on the main menu:
29
+
30
+ - **Recommended setup** — set everything up at once and see a test alert (that pages on-call) + a test incident
31
+ - **General setup** — pick any individual task (teams, on-call, status pages, integrations, and more)
32
+
33
+ Your sign-in is remembered, so the next run takes you straight to the menu.
34
+
35
+ ## Signing in
36
+
37
+ From the sign-in screen you can:
38
+
39
+ - **Browser sign-in** — authorize in your browser (OAuth).
40
+ - **API token** — paste a Rootly API token. **Recommended for the full experience** (see [Known limitations](#known-limitations)).
41
+ - **Create a Rootly account** — opens signup in your browser. Account creation is web-only (it's protected by a CAPTCHA), so the wizard hands off to the website, then you return and sign in.
42
+
43
+ To create an **API token**:
44
+
45
+ 1. Log in to Rootly.
46
+ 2. Go to **Organization Settings → API Keys**.
47
+ 3. Click **Generate New API Key**, name it, and copy the token.
48
+
49
+ **Which key type?** Use a **Global** key with write access (teams, schedules, escalation, alerts, incidents) so the wizard can complete setup, or a **Personal** key if your account can already manage those.
50
+
51
+ Docs: <https://docs.rootly.com/api-reference/overview>
52
+
53
+ Your token/session is stored in your operating system's keychain. You can also provide a token via the `ROOTLY_TOKEN` environment variable:
54
+
55
+ ```bash
56
+ ROOTLY_TOKEN=rootly_xxx npx @rootly/wizard
57
+ ```
58
+
59
+ On exit, the wizard asks whether to keep the saved sign-in or delete it from your keychain (it keeps it by default).
60
+
61
+ ## What you can do
62
+
63
+ ### Recommended setup (all-in-one)
64
+
65
+ The fastest path. In one flow it will:
66
+
67
+ 1. Create a team (or reuse one you're already on)
68
+ 2. Let you pick who joins the team and the on-call rotation
69
+ 3. Create an on-call schedule
70
+ 4. Create an escalation policy that pages the on-call schedule
71
+ 5. Add an alert source
72
+ 6. Create a public status page (and choose which components appear on it)
73
+ 7. Fire a **test alert** that **pages the on-call person** (a real call/text)
74
+ 8. Open a **test incident** (with a link to its Slack channel, if Slack is connected)
75
+
76
+ Before it runs, you can add and verify a **phone number** and connect **Slack** so the test alert actually reaches you. Anything that already exists is reused, so it's safe to re-run.
77
+
78
+ ### General setup
79
+
80
+ Jump to any individual task:
81
+
82
+ - **Teams & members** — create teams, add members from your directory
83
+ - **On-call** — schedules and escalation policies
84
+ - **Status page** — create a public status page and pick which components (services) appear on it
85
+ - **Integrations** — Slack, and alert-source handoffs (Datadog, Grafana, Sentry, PagerDuty, Opsgenie)
86
+ - **Verify** — send a test alert (pages on-call) or create a test incident
87
+ - **Inspect** — review your current teams, schedules, and coverage
88
+ - **MCP / IDE** — configure the Rootly MCP server for your editor or AI agent
89
+ - **Chat with us** — talk to our founder JJ or book a demo with sales
90
+
91
+ ### MCP / IDE setup
92
+
93
+ The wizard can write Rootly MCP server config for **Codex** (preview the config or apply it directly).
94
+
95
+ ## Scripting (advanced)
96
+
97
+ Every setup step is also available non-interactively as a JSON-in/JSON-out action — handy for automation or AI agents:
98
+
99
+ ```bash
100
+ rootly-wizard action list # list available actions
101
+ rootly-wizard action describe <name> # show an action's inputs
102
+ rootly-wizard action get-recommended-next-step
103
+ rootly-wizard action one-shot-setup '{"teamName":"Payments"}'
104
+ ```
105
+
106
+ Add `"dryRun": true` to any setup action to preview it without making changes.
107
+
108
+ ## Known limitations
109
+
110
+ The wizard is under active development. A few things to know:
111
+
112
+ - **Use an API token for the full experience.** Browser (OAuth) sign-in can create teams, schedules, escalation policies, alert sources, status pages, alerts, and incidents — but it currently can't list your user directory, so **picking other teammates falls back to just you** (a server-side fix is in progress). An API token has no such limit.
113
+ - **Account creation is browser-only** (CAPTCHA / spam protection), so the wizard hands off to the website for signup.
114
+ - **Clickable links and the logo art** depend on your terminal's capabilities; where they aren't supported, the wizard falls back to plain text.
115
+
116
+ ## Troubleshooting
117
+
118
+ - **"Sign in to Rootly" keeps appearing** — your token may be missing or invalid. Generate a fresh key (Organization Settings → API Keys) and re-enter it, or sign in with your browser.
119
+ - **Setup steps are skipped or blocked** — the sign-in needs write access. Use a Global API token with the relevant permissions, or see [Known limitations](#known-limitations).
120
+ - **Nothing happens / wrong screen** — make sure you're on Node.js 18+ and running in an interactive terminal.
121
+
122
+ ## Development
123
+
124
+ From a clone of this repo:
125
+
126
+ ```bash
127
+ node ./src/cli.js # run the wizard locally
128
+ npm test # run the test suite
129
+ ```
130
+
131
+ (Use `node ./src/cli.js` rather than `npm start` for demos — `npm start` echoes the underlying command before launching.)
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@rootly/wizard",
3
+ "version": "0.1.0",
4
+ "description": "Guided Rootly setup CLI",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "rootly-wizard": "./src/cli.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "assets",
13
+ "README.md"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Rootly-AI-Labs/rootly-wizard.git"
18
+ },
19
+ "homepage": "https://github.com/Rootly-AI-Labs/rootly-wizard#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/Rootly-AI-Labs/rootly-wizard/issues"
22
+ },
23
+ "keywords": [
24
+ "rootly",
25
+ "incident-management",
26
+ "on-call",
27
+ "cli",
28
+ "setup",
29
+ "wizard"
30
+ ],
31
+ "scripts": {
32
+ "start": "node ./src/cli.js",
33
+ "test": "node --test",
34
+ "prepublishOnly": "npm test"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "ink": "^7.0.5",
44
+ "keytar": "^7.9.0",
45
+ "react": "^19.2.7"
46
+ }
47
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ createAlertSourceAction,
3
+ createEscalationPolicyAction,
4
+ createScheduleAction,
5
+ createTeamAction
6
+ } from './setup.js';
7
+
8
+ function stepError(error) {
9
+ return error?.message?.replace(/^Rootly API request failed for [^:]+:\s*/, '') || 'unknown error';
10
+ }
11
+
12
+ export async function runGuidedSetupAction({
13
+ teamName = 'Rootly team',
14
+ description = 'Created during Rootly setup',
15
+ memberEmails = [],
16
+ handoffTime = '09:00',
17
+ repeatCount = 1,
18
+ includeAlertSource = true,
19
+ enableAlertsAndBroadcast = true
20
+ } = {}) {
21
+ const steps = [];
22
+ const data = {
23
+ team: null,
24
+ schedule: null,
25
+ escalationPolicy: null,
26
+ alertSource: null,
27
+ matchedUsers: [],
28
+ steps
29
+ };
30
+
31
+ // The team is the anchor for everything else, so a team failure is fatal.
32
+ let teamResult;
33
+ try {
34
+ teamResult = await createTeamAction({ name: teamName, description, memberEmails, enableAlertsAndBroadcast });
35
+ data.team = { id: teamResult.data.id, name: teamResult.data.name };
36
+ data.matchedUsers = teamResult.data.matchedUsers;
37
+ steps.push({ step: 'create-team', ok: true, id: teamResult.data.id, error: null });
38
+ } catch (error) {
39
+ steps.push({ step: 'create-team', ok: false, id: null, error: stepError(error) });
40
+ return {
41
+ ok: false,
42
+ code: 'GUIDED_SETUP_FAILED',
43
+ summary: 'Guided setup could not create the first team.',
44
+ data
45
+ };
46
+ }
47
+
48
+ const teamId = teamResult.data.id;
49
+ const memberIds = teamResult.data.memberIds;
50
+
51
+ try {
52
+ const schedule = await createScheduleAction({ teamId, name: `${teamName} On-Call`, handoffTime, memberIds });
53
+ data.schedule = { id: schedule.data.scheduleId };
54
+ steps.push({ step: 'create-schedule', ok: true, id: schedule.data.scheduleId, error: null });
55
+ } catch (error) {
56
+ steps.push({ step: 'create-schedule', ok: false, id: null, error: stepError(error) });
57
+ }
58
+
59
+ try {
60
+ const escalation = await createEscalationPolicyAction({
61
+ teamId,
62
+ name: `${teamName} Default Escalation`,
63
+ repeatCount,
64
+ createDefaultPath: Boolean(data.schedule)
65
+ });
66
+ data.escalationPolicy = { id: escalation.data.id, pathCreated: escalation.data.pathCreated };
67
+ steps.push({ step: 'create-escalation-policy', ok: true, id: escalation.data.id, error: escalation.data.pathError });
68
+ } catch (error) {
69
+ steps.push({ step: 'create-escalation-policy', ok: false, id: null, error: stepError(error) });
70
+ }
71
+
72
+ if (includeAlertSource) {
73
+ try {
74
+ const source = await createAlertSourceAction({ teamId, name: `${teamName} Generic webhook` });
75
+ data.alertSource = { id: source.data.id, webhookEndpoint: source.data.webhookEndpoint };
76
+ steps.push({ step: 'create-alert-source', ok: true, id: source.data.id, error: null });
77
+ } catch (error) {
78
+ steps.push({ step: 'create-alert-source', ok: false, id: null, error: stepError(error) });
79
+ }
80
+ }
81
+
82
+ return {
83
+ ok: true,
84
+ summary: `Ran guided setup for ${teamName}.`,
85
+ data
86
+ };
87
+ }
@@ -0,0 +1,341 @@
1
+ import { getActiveToken, isServiceAccount, loadApiClient, loadOnboardingState } from '../runtime.js';
2
+
3
+ // Services + functionalities a status page can show as components. Values are
4
+ // encoded "service:<id>" / "functionality:<id>" so the picker can split them.
5
+ export async function getStatusPageComponentsAction() {
6
+ const api = await loadApiClient();
7
+ const [services, functionalities] = await Promise.all([
8
+ api.listServices().catch(() => ({ data: [] })),
9
+ api.listFunctionalities().catch(() => ({ data: [] }))
10
+ ]);
11
+ const components = [
12
+ ...((services?.data) || []).map((s) => ({ label: `${s.attributes?.name || s.id} · service`, value: `service:${s.id}` })),
13
+ ...((functionalities?.data) || []).map((f) => ({ label: `${f.attributes?.name || f.id} · functionality`, value: `functionality:${f.id}` }))
14
+ ];
15
+ return { ok: true, data: { components } };
16
+ }
17
+
18
+ // Create a custom status-page component. A typed-in component becomes a new
19
+ // Service (each service carries its own operational status, so it renders as a
20
+ // component on the public page), then gets added to the page by id. Returns the
21
+ // encoded component for the picker.
22
+ export async function createCustomComponentAction({ name }) {
23
+ const clean = String(name || '').trim();
24
+ if (!clean) {
25
+ return { ok: false, summary: 'A component name is required.' };
26
+ }
27
+ const api = await loadApiClient();
28
+ const payload = await api.createService({ name: clean });
29
+ const id = payload?.data?.id;
30
+ if (!id) {
31
+ return { ok: false, summary: 'Rootly did not return the new component.' };
32
+ }
33
+ return {
34
+ ok: true,
35
+ data: {
36
+ id,
37
+ name: payload?.data?.attributes?.name || clean,
38
+ component: { label: `${payload?.data?.attributes?.name || clean} · service`, value: `service:${id}` }
39
+ }
40
+ };
41
+ }
42
+
43
+ export async function getStatusAction() {
44
+ const state = await loadOnboardingState();
45
+ if (!state) {
46
+ return {
47
+ ok: false,
48
+ code: 'NO_AUTH',
49
+ summary: 'No auth context found.',
50
+ data: null
51
+ };
52
+ }
53
+
54
+ return {
55
+ ok: true,
56
+ summary: 'Loaded workspace status.',
57
+ data: {
58
+ nextBestAction: state.onboarding.nextBestAction,
59
+ workspace: state.teams.workspace?.name || state.teams.workspace?.slug || 'Connected Rootly account',
60
+ teams: state.teams.total,
61
+ teamsWithMembers: state.teams.teamsWithMembers,
62
+ teamsWithSchedules: state.teams.teamsWithSchedules,
63
+ teamsWithEscalationPolicies: state.teams.teamsWithEscalationPolicies,
64
+ teamsWithSlackSignals: state.teams.teamsWithSlack,
65
+ readiness: state.onboarding.readiness
66
+ }
67
+ };
68
+ }
69
+
70
+ export async function getReadinessAction() {
71
+ const state = await loadOnboardingState();
72
+ if (!state) {
73
+ return {
74
+ ok: false,
75
+ code: 'NO_AUTH',
76
+ summary: 'No auth context found.',
77
+ data: null
78
+ };
79
+ }
80
+
81
+ return {
82
+ ok: true,
83
+ summary: 'Loaded onboarding readiness.',
84
+ data: {
85
+ nextBestAction: state.onboarding.nextBestAction,
86
+ workspace: state.teams.workspace,
87
+ onboarding: state.onboarding,
88
+ teams: state.teams
89
+ }
90
+ };
91
+ }
92
+
93
+ export async function getTeamsAction() {
94
+ const state = await loadOnboardingState();
95
+ if (!state) {
96
+ return {
97
+ ok: false,
98
+ code: 'NO_AUTH',
99
+ summary: 'No auth context found.',
100
+ data: null
101
+ };
102
+ }
103
+
104
+ return {
105
+ ok: true,
106
+ summary: 'Loaded teams.',
107
+ data: {
108
+ workspace: state.teams.workspace,
109
+ summary: {
110
+ total: state.teams.total,
111
+ teamsWithMembers: state.teams.teamsWithMembers,
112
+ teamsWithSchedules: state.teams.teamsWithSchedules,
113
+ teamsWithEscalationPolicies: state.teams.teamsWithEscalationPolicies,
114
+ teamsWithSlackSignals: state.teams.teamsWithSlack
115
+ },
116
+ teams: state.teams.all
117
+ }
118
+ };
119
+ }
120
+
121
+ export async function getSchedulesAction() {
122
+ const token = await getActiveToken();
123
+ if (!token) {
124
+ return {
125
+ ok: false,
126
+ code: 'NO_AUTH',
127
+ summary: 'No auth context found.',
128
+ data: null
129
+ };
130
+ }
131
+
132
+ const api = await loadApiClient();
133
+ const schedulesPayload = await api.listSchedules();
134
+ const schedules = schedulesPayload?.data || [];
135
+
136
+ return {
137
+ ok: true,
138
+ summary: 'Loaded schedules.',
139
+ data: {
140
+ summary: {
141
+ total: schedules.length,
142
+ schedulesWithCoverage: schedules.filter((schedule) => schedule?.attributes?.all_time_coverage).length,
143
+ schedulesWithTeams: schedules.filter((schedule) =>
144
+ Array.isArray(schedule?.attributes?.owner_group_ids) && schedule.attributes.owner_group_ids.length > 0
145
+ ).length
146
+ },
147
+ schedules
148
+ }
149
+ };
150
+ }
151
+
152
+ export async function getEscalationPoliciesAction() {
153
+ const token = await getActiveToken();
154
+ if (!token) {
155
+ return {
156
+ ok: false,
157
+ code: 'NO_AUTH',
158
+ summary: 'No auth context found.',
159
+ data: null
160
+ };
161
+ }
162
+
163
+ const api = await loadApiClient();
164
+ const policiesPayload = await api.listEscalationPolicies();
165
+ const policies = policiesPayload?.data || [];
166
+
167
+ return {
168
+ ok: true,
169
+ summary: 'Loaded escalation policies.',
170
+ data: {
171
+ summary: {
172
+ total: policies.length,
173
+ policiesWithTeams: policies.filter((policy) =>
174
+ Array.isArray(policy?.attributes?.group_ids) && policy.attributes.group_ids.length > 0
175
+ ).length,
176
+ repeatingPolicies: policies.filter((policy) => (policy?.attributes?.repeat_count ?? 0) > 0).length
177
+ },
178
+ policies
179
+ }
180
+ };
181
+ }
182
+
183
+ async function listResourceAction(method, label, mapItem) {
184
+ const token = await getActiveToken();
185
+ if (!token) {
186
+ return {
187
+ ok: false,
188
+ code: 'NO_AUTH',
189
+ summary: 'No auth context found.',
190
+ data: null
191
+ };
192
+ }
193
+
194
+ const api = await loadApiClient();
195
+ const payload = await api[method]();
196
+ const items = (payload?.data || []).map(mapItem);
197
+
198
+ return {
199
+ ok: true,
200
+ summary: `Loaded ${label}.`,
201
+ data: { total: items.length, items }
202
+ };
203
+ }
204
+
205
+ export async function getTeamMembersAction({ teamId } = {}) {
206
+ const token = await getActiveToken();
207
+ if (!token) {
208
+ return { ok: false, code: 'NO_AUTH', summary: 'No auth context found.', data: null };
209
+ }
210
+
211
+ const api = await loadApiClient();
212
+ const payload = await api.getTeam(teamId);
213
+ const team = payload?.data;
214
+ const usersById = new Map((payload?.included || [])
215
+ .filter((record) => record.type === 'users')
216
+ .map((record) => [record.id, record]));
217
+
218
+ const members = (team?.relationships?.users?.data || []).map(({ id }) => {
219
+ const record = usersById.get(id);
220
+ return {
221
+ id,
222
+ email: record?.attributes?.email || null,
223
+ name: record?.attributes?.full_name || record?.attributes?.name || null,
224
+ serviceAccount: isServiceAccount(record)
225
+ };
226
+ });
227
+
228
+ return {
229
+ ok: true,
230
+ summary: `Loaded members for team ${teamId}.`,
231
+ data: {
232
+ teamId,
233
+ teamName: team?.attributes?.name || teamId,
234
+ total: members.length,
235
+ members
236
+ }
237
+ };
238
+ }
239
+
240
+ function usersLookupUnavailable(error) {
241
+ const message = error?.message || '';
242
+ return message.includes('/v1/users') && message.includes('404');
243
+ }
244
+
245
+ export async function getDirectoryUsersAction() {
246
+ const token = await getActiveToken();
247
+ if (!token) {
248
+ return { ok: false, code: 'NO_AUTH', summary: 'No auth context found.', data: null };
249
+ }
250
+
251
+ const api = await loadApiClient();
252
+
253
+ // Limited OAuth sessions can't read /v1/users; flag that so the caller can
254
+ // fall back gracefully instead of failing.
255
+ let allUsers = [];
256
+ let userLookupUnavailable = false;
257
+ try {
258
+ allUsers = await api.listAllUsers();
259
+ } catch (error) {
260
+ if (usersLookupUnavailable(error)) {
261
+ userLookupUnavailable = true;
262
+ } else {
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ const users = allUsers
268
+ .filter((record) => !isServiceAccount(record))
269
+ .map((record) => ({
270
+ id: String(record.id),
271
+ name: record?.attributes?.full_name || record?.attributes?.name || null,
272
+ email: record?.attributes?.email || null
273
+ }));
274
+
275
+ return {
276
+ ok: true,
277
+ summary: `Loaded ${users.length} directory user(s).`,
278
+ data: { total: users.length, users, userLookupUnavailable }
279
+ };
280
+ }
281
+
282
+ export async function getAddableTeamMembersAction({ teamId } = {}) {
283
+ const token = await getActiveToken();
284
+ if (!token) {
285
+ return { ok: false, code: 'NO_AUTH', summary: 'No auth context found.', data: null };
286
+ }
287
+
288
+ const api = await loadApiClient();
289
+ const teamPayload = await api.getTeam(teamId);
290
+ const existing = new Set((teamPayload?.data?.attributes?.user_ids || []).map((id) => String(id)));
291
+
292
+ // Some sign-ins (notably limited OAuth sessions) cannot read /v1/users.
293
+ // Surface that as a flag so the caller can fall back to inviting by email.
294
+ let allUsers = [];
295
+ let userLookupUnavailable = false;
296
+ try {
297
+ allUsers = await api.listAllUsers();
298
+ } catch (error) {
299
+ if (usersLookupUnavailable(error)) {
300
+ userLookupUnavailable = true;
301
+ } else {
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ const addable = allUsers
307
+ .filter((record) => !isServiceAccount(record))
308
+ .map((record) => ({
309
+ id: String(record.id),
310
+ name: record?.attributes?.full_name || record?.attributes?.name || null,
311
+ email: record?.attributes?.email || null
312
+ }))
313
+ .filter((user) => !existing.has(user.id));
314
+
315
+ return {
316
+ ok: true,
317
+ summary: `Loaded addable members for team ${teamId}.`,
318
+ data: {
319
+ teamId,
320
+ teamName: teamPayload?.data?.attributes?.name || teamId,
321
+ total: addable.length,
322
+ addable,
323
+ userLookupUnavailable
324
+ }
325
+ };
326
+ }
327
+
328
+ const byName = (record) => ({
329
+ id: record.id,
330
+ name: record?.attributes?.name || record?.attributes?.slug || record.id
331
+ });
332
+
333
+ export const getServicesAction = () => listResourceAction('listServices', 'services', byName);
334
+ export const getSeveritiesAction = () => listResourceAction('listSeverities', 'severities', byName);
335
+ export const getEnvironmentsAction = () => listResourceAction('listEnvironments', 'environments', byName);
336
+ export const getIncidentTypesAction = () => listResourceAction('listIncidentTypes', 'incident types', byName);
337
+ export const getUsersAction = () => listResourceAction('listUsers', 'users', (record) => ({
338
+ id: record.id,
339
+ email: record?.attributes?.email || null,
340
+ name: record?.attributes?.full_name || record?.attributes?.name || null
341
+ }));