@rootly/wizard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/rootly-logo-glyph-purple.png +0 -0
- package/assets/rootly-logo-glyph.png +0 -0
- package/assets/welcome-screen.png +0 -0
- package/package.json +47 -0
- package/src/actions/guided.js +87 -0
- package/src/actions/inspect.js +341 -0
- package/src/actions/integrations.js +75 -0
- package/src/actions/mcp.js +52 -0
- package/src/actions/oneshot.js +243 -0
- package/src/actions/phone.js +122 -0
- package/src/actions/registry.js +373 -0
- package/src/actions/setup.js +574 -0
- package/src/actions/testing.js +141 -0
- package/src/actions/workflow.js +47 -0
- package/src/auth.js +553 -0
- package/src/cli.js +199 -0
- package/src/detect-state.js +232 -0
- package/src/format.js +43 -0
- package/src/mcp.js +254 -0
- package/src/rootly-api.js +325 -0
- package/src/runtime.js +55 -0
- package/src/tui/components/AppShell.js +68 -0
- package/src/tui/components/Banner.js +145 -0
- package/src/tui/components/BigText.js +46 -0
- package/src/tui/components/Celebration.js +47 -0
- package/src/tui/components/KeyValueList.js +22 -0
- package/src/tui/components/MenuList.js +170 -0
- package/src/tui/components/MultiSelectList.js +181 -0
- package/src/tui/components/NoticeBox.js +56 -0
- package/src/tui/components/SlideReveal.js +52 -0
- package/src/tui/index.js +2078 -0
- package/src/tui/screens/ListScreen.js +29 -0
- package/src/tui/screens/LoadFailedScreen.js +30 -0
- package/src/tui/screens/LoadingScreen.js +32 -0
- package/src/tui/screens/MainMenuScreen.js +81 -0
- package/src/tui/screens/MultiSelectScreen.js +17 -0
- package/src/tui/screens/OneShotRunnerScreen.js +186 -0
- package/src/tui/screens/OptionScreen.js +24 -0
- package/src/tui/screens/ResultScreen.js +35 -0
- package/src/tui/screens/StatusScreen.js +70 -0
- package/src/tui/screens/TextEntryScreen.js +115 -0
- package/src/tui/screens/WelcomeScreen.js +66 -0
- package/src/tui/theme.js +69 -0
- package/src/tui-legacy-bridge.js +371 -0
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
|
+

|
|
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
|
|
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
|
+
}));
|