@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/src/cli.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { startInteractiveWizard } from './tui/index.js';
|
|
4
|
+
import {
|
|
5
|
+
ACTIONS,
|
|
6
|
+
buildActionCatalog,
|
|
7
|
+
buildToolSpecs,
|
|
8
|
+
describeAction,
|
|
9
|
+
toStructuredError,
|
|
10
|
+
validateInput
|
|
11
|
+
} from './actions/registry.js';
|
|
12
|
+
|
|
13
|
+
function emitAction(result) {
|
|
14
|
+
console.log(JSON.stringify(result, null, 2));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function agentQuickstart() {
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
summary: 'Rootly Wizard agent interface.',
|
|
21
|
+
data: {
|
|
22
|
+
usage: 'rootly-wizard action <name> [json]',
|
|
23
|
+
conventions: [
|
|
24
|
+
'Input is a single JSON object as the last argument; output is one JSON object on stdout.',
|
|
25
|
+
'Success: { ok:true, summary, data }. Failure: { ok:false, code, error, field?, retryable }.',
|
|
26
|
+
'Auth: set ROOTLY_TOKEN to an organization admin Rootly API key.',
|
|
27
|
+
'Add "dryRun": true to any mutating action to preview without executing.'
|
|
28
|
+
],
|
|
29
|
+
discover: {
|
|
30
|
+
list: 'rootly-wizard action list',
|
|
31
|
+
describe: 'rootly-wizard action describe <name>',
|
|
32
|
+
tools: 'rootly-wizard action tools'
|
|
33
|
+
},
|
|
34
|
+
examples: [
|
|
35
|
+
'rootly-wizard action get-recommended-next-step',
|
|
36
|
+
'rootly-wizard action list-services',
|
|
37
|
+
'rootly-wizard action run-guided-setup \'{"teamName":"Payments"}\''
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runActionCommand() {
|
|
44
|
+
const actionName = process.argv[3];
|
|
45
|
+
const rawInput = process.argv[4];
|
|
46
|
+
|
|
47
|
+
if (!actionName || actionName === 'help') {
|
|
48
|
+
emitAction(agentQuickstart());
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (actionName === 'list') {
|
|
53
|
+
emitAction({ ok: true, summary: 'Available actions.', data: { actions: buildActionCatalog() } });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (actionName === 'tools') {
|
|
58
|
+
emitAction({ ok: true, summary: 'Function-calling tool definitions.', data: { tools: buildToolSpecs() } });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (actionName === 'describe') {
|
|
63
|
+
const described = describeAction(rawInput);
|
|
64
|
+
if (!described) {
|
|
65
|
+
emitAction({
|
|
66
|
+
ok: false,
|
|
67
|
+
code: 'UNKNOWN_ACTION',
|
|
68
|
+
summary: `Unknown action: ${rawInput || '(missing)'}`,
|
|
69
|
+
data: { actions: Object.keys(ACTIONS) }
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
emitAction({ ok: true, summary: `Schema for ${rawInput}.`, data: described });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const entry = ACTIONS[actionName];
|
|
78
|
+
if (!entry) {
|
|
79
|
+
emitAction({
|
|
80
|
+
ok: false,
|
|
81
|
+
code: 'UNKNOWN_ACTION',
|
|
82
|
+
summary: `Unknown action: ${actionName || '(missing)'}`,
|
|
83
|
+
data: {
|
|
84
|
+
actions: Object.keys(ACTIONS),
|
|
85
|
+
hint: 'Run `action list` for descriptions or `action describe <name>` for input schema.'
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let inputPayload;
|
|
92
|
+
try {
|
|
93
|
+
inputPayload = rawInput ? JSON.parse(rawInput) : {};
|
|
94
|
+
} catch {
|
|
95
|
+
emitAction({
|
|
96
|
+
ok: false,
|
|
97
|
+
code: 'BAD_INPUT',
|
|
98
|
+
summary: `Could not parse input for ${actionName}.`,
|
|
99
|
+
error: 'The input argument was not valid JSON.',
|
|
100
|
+
data: null
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const invalid = validateInput(entry.input, inputPayload);
|
|
106
|
+
if (invalid) {
|
|
107
|
+
emitAction({
|
|
108
|
+
ok: false,
|
|
109
|
+
code: 'VALIDATION',
|
|
110
|
+
summary: `Invalid input for ${actionName}.`,
|
|
111
|
+
field: invalid.field,
|
|
112
|
+
error: invalid.message,
|
|
113
|
+
retryable: false,
|
|
114
|
+
data: null
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (entry.mutates && inputPayload.dryRun === true) {
|
|
120
|
+
const { dryRun, ...input } = inputPayload;
|
|
121
|
+
emitAction({
|
|
122
|
+
ok: true,
|
|
123
|
+
code: 'DRY_RUN',
|
|
124
|
+
summary: `Dry run: ${actionName} was not executed.`,
|
|
125
|
+
data: { action: actionName, mutates: true, input }
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
emitAction(await entry.handler(inputPayload));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
emitAction(toStructuredError(actionName, error));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function printHelp() {
|
|
138
|
+
console.log('Rootly Wizard');
|
|
139
|
+
console.log('A guided onboarding CLI for getting Rootly operational quickly.');
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log('Usage:');
|
|
142
|
+
console.log(' rootly-wizard Start the interactive guided setup');
|
|
143
|
+
console.log(' rootly-wizard action <name> [json] Run a single action non-interactively (JSON in/out)');
|
|
144
|
+
console.log(' rootly-wizard action list List available actions (JSON)');
|
|
145
|
+
console.log(' rootly-wizard action describe <name> Show an action input schema (JSON)');
|
|
146
|
+
console.log(' rootly-wizard action tools Emit function-calling tool defs (JSON)');
|
|
147
|
+
console.log(' rootly-wizard action help Agent quickstart (JSON)');
|
|
148
|
+
console.log(' rootly-wizard help Show this help');
|
|
149
|
+
console.log('');
|
|
150
|
+
console.log('The interactive wizard handles sign-in, status, inspect, setup, integrations,');
|
|
151
|
+
console.log('verify, MCP setup, and disconnect from a single menu.');
|
|
152
|
+
console.log('Preferred run path: `rootly-wizard` or `npx @rootly/wizard` once published.');
|
|
153
|
+
console.log('Local development: `node ./src/cli.js`.');
|
|
154
|
+
console.log('Auth: set ROOTLY_TOKEN, or sign in on first run (browser or API key).');
|
|
155
|
+
console.log('Agents: see AGENTS.md. Pass {"dryRun": true} to any mutating action to preview.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatTopLevelError(error) {
|
|
159
|
+
const message = error?.message || String(error);
|
|
160
|
+
|
|
161
|
+
if (/\b401\b/.test(message)) {
|
|
162
|
+
return 'Rootly rejected the token (401). Disconnect from the wizard menu, then sign in again.';
|
|
163
|
+
}
|
|
164
|
+
if (/\b403\b/.test(message)) {
|
|
165
|
+
return 'Rootly denied access (403). This wizard expects an admin, organization-wide API key — check your token scope.';
|
|
166
|
+
}
|
|
167
|
+
if (error?.name === 'TimeoutError' || error?.name === 'AbortError' || /timed out|aborted/i.test(message)) {
|
|
168
|
+
return 'A Rootly API request timed out. Check your network connection and try again.';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return message;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function main() {
|
|
175
|
+
const entry = process.argv[2];
|
|
176
|
+
|
|
177
|
+
if (entry === 'action') {
|
|
178
|
+
await runActionCommand();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (entry === 'help' || entry === '--help' || entry === '-h') {
|
|
183
|
+
printHelp();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
188
|
+
console.log('An interactive terminal is required. Run this in a normal shell session.');
|
|
189
|
+
process.exitCode = 1;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await startInteractiveWizard();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
main().catch((error) => {
|
|
197
|
+
console.error(formatTopLevelError(error));
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
function toIdSet(items = []) {
|
|
2
|
+
return new Set(items.map((item) => item.id));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function userTeamIds(userPayload) {
|
|
6
|
+
return userPayload?.data?.relationships?.teams?.data?.map((team) => team.id) || [];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function currentWorkspaceId(userPayload) {
|
|
10
|
+
return userPayload?.data?.relationships?.current_team?.data?.id || null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function inferredWorkspaceName(userPayload) {
|
|
14
|
+
const explicitName =
|
|
15
|
+
userPayload?.data?.attributes?.current_team_name ||
|
|
16
|
+
userPayload?.data?.attributes?.team_name ||
|
|
17
|
+
null;
|
|
18
|
+
|
|
19
|
+
if (explicitName) {
|
|
20
|
+
return explicitName;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const decorated = userPayload?.data?.attributes?.full_name_with_team || '';
|
|
24
|
+
const match = decorated.match(/^\[(.+?)\]/);
|
|
25
|
+
return match?.[1] || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getSlackId(userPayload) {
|
|
29
|
+
return userPayload?.data?.attributes?.slack_id || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getRelationshipIds(entity, relationshipName) {
|
|
33
|
+
return entity?.relationships?.[relationshipName]?.data?.map((item) => item.id) || [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getTeamAttributes(team) {
|
|
37
|
+
return team?.attributes || {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function countSlackCoverageSignals(attrs = {}) {
|
|
41
|
+
const scalarSignals = [
|
|
42
|
+
attrs.slack_channel_id,
|
|
43
|
+
attrs.slack_channel_name,
|
|
44
|
+
attrs.slack_channel,
|
|
45
|
+
attrs.slack_group_id,
|
|
46
|
+
attrs.slack_workspace_id
|
|
47
|
+
].filter((value) => value !== null && value !== undefined && value !== '');
|
|
48
|
+
|
|
49
|
+
const arraySignals = [
|
|
50
|
+
...(Array.isArray(attrs.slack_channels) ? attrs.slack_channels : []),
|
|
51
|
+
...(Array.isArray(attrs.slack_aliases) ? attrs.slack_aliases : [])
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
return scalarSignals.length + arraySignals.length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function count(items) {
|
|
58
|
+
return Array.isArray(items) ? items.length : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function summarizeStage(doneCount, totalCount) {
|
|
62
|
+
if (doneCount <= 0) {
|
|
63
|
+
return 'needed';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (doneCount >= totalCount) {
|
|
67
|
+
return 'done';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return 'in-progress';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function teamCoverageLabel(doneCount, totalCount) {
|
|
74
|
+
if (totalCount <= 0) {
|
|
75
|
+
return 'needed';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (doneCount <= 0) {
|
|
79
|
+
return 'needed';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (doneCount >= totalCount) {
|
|
83
|
+
return 'done';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return 'in-progress';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function detectOnboardingState({ userPayload, teamsPayload, schedulesPayload, escalationPoliciesPayload, authMode = 'stored-token' }) {
|
|
90
|
+
const user = userPayload?.data?.attributes || {};
|
|
91
|
+
const teams = teamsPayload?.data || [];
|
|
92
|
+
const schedules = schedulesPayload?.data || [];
|
|
93
|
+
const escalationPolicies = escalationPoliciesPayload?.data || [];
|
|
94
|
+
|
|
95
|
+
const teamsById = new Map(teams.map((team) => [team.id, team]));
|
|
96
|
+
const workspaceId = currentWorkspaceId(userPayload);
|
|
97
|
+
const workspace = workspaceId ? teamsById.get(workspaceId) : null;
|
|
98
|
+
const currentUserTeamIds = userTeamIds(userPayload);
|
|
99
|
+
const currentUserTeams = currentUserTeamIds.map((id) => teamsById.get(id)).filter(Boolean);
|
|
100
|
+
const teamsWithSchedules = teams.filter((team) => count(getRelationshipIds(team, 'schedules')) > 0);
|
|
101
|
+
const teamsWithEscalationPolicies = teams.filter((team) => count(getRelationshipIds(team, 'escalation_policies')) > 0);
|
|
102
|
+
const teamsWithSlack = teams.filter((team) => {
|
|
103
|
+
const attrs = getTeamAttributes(team);
|
|
104
|
+
return countSlackCoverageSignals(attrs) > 0;
|
|
105
|
+
});
|
|
106
|
+
const alertableTeams = teams.filter((team) => {
|
|
107
|
+
const attrs = getTeamAttributes(team);
|
|
108
|
+
const teamSchedules = getRelationshipIds(team, 'schedules');
|
|
109
|
+
const teamPolicies = getRelationshipIds(team, 'escalation_policies');
|
|
110
|
+
|
|
111
|
+
return teamSchedules.length > 0 || teamPolicies.length > 0 || Boolean(attrs.alert_source_count || attrs.alert_sources_count);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const hasTeams = teams.length > 0;
|
|
115
|
+
const hasAnySchedules = schedules.length > 0;
|
|
116
|
+
const hasAnyEscalationPolicies = escalationPolicies.length > 0;
|
|
117
|
+
const hasSlack = Boolean(getSlackId(userPayload));
|
|
118
|
+
const hasUserTeam = currentUserTeams.length > 0;
|
|
119
|
+
const userHasSchedule = currentUserTeams.some((team) => getRelationshipIds(team, 'schedules').length > 0);
|
|
120
|
+
const userHasEscalationPolicy = currentUserTeams.some((team) => getRelationshipIds(team, 'escalation_policies').length > 0);
|
|
121
|
+
const userTeamsWithSlack = currentUserTeams.filter((team) => {
|
|
122
|
+
const attrs = getTeamAttributes(team);
|
|
123
|
+
return countSlackCoverageSignals(attrs) > 0;
|
|
124
|
+
});
|
|
125
|
+
const hasAnySlackConfigured = hasSlack || teamsWithSlack.length > 0 || userTeamsWithSlack.length > 0;
|
|
126
|
+
const hasAnyAlertingReadyTeam = alertableTeams.length > 0;
|
|
127
|
+
const hasAnyTeamMembership = currentUserTeams.length > 0;
|
|
128
|
+
const teamCount = teams.length;
|
|
129
|
+
const teamsWithSchedulesCount = teamsWithSchedules.length;
|
|
130
|
+
const teamsWithEscalationPoliciesCount = teamsWithEscalationPolicies.length;
|
|
131
|
+
const teamsWithSlackCount = teamsWithSlack.length;
|
|
132
|
+
const teamsWithMembersCount = teams.filter((team) => count(getRelationshipIds(team, 'users')) > 0).length;
|
|
133
|
+
|
|
134
|
+
const nextBestAction = !hasTeams
|
|
135
|
+
? 'create-team'
|
|
136
|
+
: teamsWithMembersCount === 0
|
|
137
|
+
? 'invite-team-members'
|
|
138
|
+
: teamsWithSchedulesCount === 0
|
|
139
|
+
? 'create-schedule'
|
|
140
|
+
: teamsWithEscalationPoliciesCount === 0
|
|
141
|
+
? 'create-escalation-policy'
|
|
142
|
+
: !hasAnyAlertingReadyTeam
|
|
143
|
+
? 'hook-up-monitor'
|
|
144
|
+
: 'run-guided-setup';
|
|
145
|
+
|
|
146
|
+
const steps = {
|
|
147
|
+
createTeam: hasTeams ? 'done' : 'needed',
|
|
148
|
+
inviteTeamMembers: teamsWithMembersCount > 0 ? 'done' : 'needed',
|
|
149
|
+
createSchedule: teamsWithSchedulesCount > 0 ? 'done' : 'needed',
|
|
150
|
+
createEscalationPolicy: teamsWithEscalationPoliciesCount > 0 ? 'done' : 'needed',
|
|
151
|
+
hookUpMonitor: hasAnyAlertingReadyTeam ? 'done' : 'needed',
|
|
152
|
+
testPage: hasAnyAlertingReadyTeam ? 'maybe-needed' : 'blocked',
|
|
153
|
+
connectSlack: hasAnySlackConfigured ? 'maybe-needed' : 'needed',
|
|
154
|
+
createTestIncident: 'maybe-needed'
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const readiness = {
|
|
158
|
+
workspaceSetup: summarizeStage(
|
|
159
|
+
[hasTeams].filter(Boolean).length,
|
|
160
|
+
1
|
|
161
|
+
),
|
|
162
|
+
groupSetup: teamCoverageLabel(
|
|
163
|
+
teamsWithMembersCount + teamsWithSchedulesCount + teamsWithEscalationPoliciesCount,
|
|
164
|
+
teamCount * 3
|
|
165
|
+
),
|
|
166
|
+
alertingSetup: summarizeStage(
|
|
167
|
+
[hasAnyAlertingReadyTeam, hasAnySchedules, hasAnyEscalationPolicies].filter(Boolean).length,
|
|
168
|
+
3
|
|
169
|
+
),
|
|
170
|
+
incidentSetup: teamsWithSlackCount > 0 || hasSlack ? 'in-progress' : 'needed'
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
auth: {
|
|
175
|
+
valid: true,
|
|
176
|
+
mode: authMode
|
|
177
|
+
},
|
|
178
|
+
user: {
|
|
179
|
+
id: userPayload?.data?.id || null,
|
|
180
|
+
email: user.email || null,
|
|
181
|
+
name: user.full_name || user.name || null,
|
|
182
|
+
slackConnected: hasSlack,
|
|
183
|
+
teams: currentUserTeams.map((team) => ({
|
|
184
|
+
id: team.id,
|
|
185
|
+
name: team.attributes?.name,
|
|
186
|
+
slug: team.attributes?.slug
|
|
187
|
+
}))
|
|
188
|
+
},
|
|
189
|
+
teams: {
|
|
190
|
+
workspace: {
|
|
191
|
+
id: workspace?.id || workspaceId || null,
|
|
192
|
+
name: workspace?.attributes?.name || inferredWorkspaceName(userPayload),
|
|
193
|
+
slug: workspace?.attributes?.slug || null
|
|
194
|
+
},
|
|
195
|
+
total: teams.length,
|
|
196
|
+
hasTeams,
|
|
197
|
+
hasAnyTeamMembership,
|
|
198
|
+
all: teams.map((team) => ({
|
|
199
|
+
id: team.id,
|
|
200
|
+
name: team.attributes?.name,
|
|
201
|
+
userIds: getRelationshipIds(team, 'users'),
|
|
202
|
+
memberCount: count(team?.relationships?.users?.data),
|
|
203
|
+
scheduleCount: count(team?.relationships?.schedules?.data),
|
|
204
|
+
escalationPolicyCount: count(team?.relationships?.escalation_policies?.data)
|
|
205
|
+
})),
|
|
206
|
+
userTeams: currentUserTeams.map((team) => ({
|
|
207
|
+
id: team.id,
|
|
208
|
+
name: team.attributes?.name,
|
|
209
|
+
scheduleCount: count(team?.relationships?.schedules?.data),
|
|
210
|
+
escalationPolicyCount: count(team?.relationships?.escalation_policies?.data)
|
|
211
|
+
})),
|
|
212
|
+
hasAnySchedules,
|
|
213
|
+
hasAnyEscalationPolicies,
|
|
214
|
+
hasAnySlackConfigured,
|
|
215
|
+
hasAnyAlertingReadyTeam,
|
|
216
|
+
teamsWithMembers: teamsWithMembersCount,
|
|
217
|
+
teamsWithSchedules: teamsWithSchedules.length,
|
|
218
|
+
teamsWithEscalationPolicies: teamsWithEscalationPolicies.length,
|
|
219
|
+
teamsWithSlack: teamsWithSlack.length
|
|
220
|
+
},
|
|
221
|
+
onboarding: {
|
|
222
|
+
completed:
|
|
223
|
+
nextBestAction === 'run-guided-setup' &&
|
|
224
|
+
hasAnySchedules &&
|
|
225
|
+
hasAnyEscalationPolicies &&
|
|
226
|
+
hasAnyAlertingReadyTeam,
|
|
227
|
+
nextBestAction,
|
|
228
|
+
readiness,
|
|
229
|
+
steps
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Wrap a URL in an OSC 8 terminal hyperlink so supporting terminals render it
|
|
2
|
+
// as a clickable link. Keeps the URL itself as the visible text by default, so
|
|
3
|
+
// it stays readable/copyable where hyperlinks aren't supported. Non-TTY output
|
|
4
|
+
// (pipes, CI) returns the plain label.
|
|
5
|
+
export function hyperlink(url, label = url) {
|
|
6
|
+
if (!process.stdout || !process.stdout.isTTY) return label;
|
|
7
|
+
const OSC = ']8;;';
|
|
8
|
+
const BEL = '';
|
|
9
|
+
return `${OSC}${url}${BEL}${label}${OSC}${BEL}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Format an E.164-ish phone number for display. US/Canada (+1 + 10 digits)
|
|
13
|
+
// becomes +1 (415) 706-8600; anything else is returned as-is.
|
|
14
|
+
export function formatPhone(raw) {
|
|
15
|
+
const s = String(raw || '').trim();
|
|
16
|
+
const m = s.match(/^\+1(\d{3})(\d{3})(\d{4})$/);
|
|
17
|
+
return m ? `+1 (${m[1]}) ${m[2]}-${m[3]}` : s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Turn a raw API/action error string into a short, human phrase suitable for a
|
|
21
|
+
// result screen. Passes already-clean text through unchanged, so it's safe to
|
|
22
|
+
// wrap any failure summary with it.
|
|
23
|
+
export function friendlyError(raw) {
|
|
24
|
+
const s = String(raw || '');
|
|
25
|
+
if (/already been taken|already exists/i.test(s)) return 'This already exists — it looks set up already.';
|
|
26
|
+
if (/not found or unauthorized/i.test(s)) return 'Not permitted by this sign-in.';
|
|
27
|
+
if (/\b429\b|rate limit/i.test(s)) return 'Rate limited — try again shortly.';
|
|
28
|
+
// Pull a human detail out of a JSON error body if present.
|
|
29
|
+
const brace = s.indexOf('{');
|
|
30
|
+
if (brace !== -1) {
|
|
31
|
+
try {
|
|
32
|
+
const body = JSON.parse(s.slice(brace));
|
|
33
|
+
const first = Array.isArray(body?.errors) ? body.errors[0] : null;
|
|
34
|
+
const detail = first?.detail || first?.title
|
|
35
|
+
|| (body && typeof body === 'object' ? Object.values(body).flat()[0] : null);
|
|
36
|
+
if (detail) return String(detail);
|
|
37
|
+
} catch {
|
|
38
|
+
// fall through
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Strip a leading "NNN - " HTTP status prefix if present.
|
|
42
|
+
return s.replace(/^\d{3}\s*-\s*/, '').trim() || 'Something went wrong. Please try again.';
|
|
43
|
+
}
|