@uptimerobot/cli 0.3.0 → 1.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/README.md +68 -2
- package/bin/run.js +24 -2
- package/dist/api/errors.js +20 -1
- package/dist/commands/integrations/create/discord.js +10 -0
- package/dist/commands/integrations/create/google-chat.js +10 -0
- package/dist/commands/integrations/create/mattermost.js +10 -0
- package/dist/commands/integrations/create/msteams.js +10 -0
- package/dist/commands/integrations/create/pagerduty.js +10 -0
- package/dist/commands/integrations/create/push-bullet.js +10 -0
- package/dist/commands/integrations/create/pushover.js +10 -0
- package/dist/commands/integrations/create/slack.js +10 -0
- package/dist/commands/integrations/create/splunk.js +10 -0
- package/dist/commands/integrations/create/telegram.js +10 -0
- package/dist/commands/integrations/create/webhook.js +10 -0
- package/dist/commands/integrations/create/zapier.js +10 -0
- package/dist/commands/integrations/update/discord.js +10 -0
- package/dist/commands/integrations/update/google-chat.js +10 -0
- package/dist/commands/integrations/update/mattermost.js +10 -0
- package/dist/commands/integrations/update/msteams.js +10 -0
- package/dist/commands/integrations/update/pagerduty.js +10 -0
- package/dist/commands/integrations/update/push-bullet.js +10 -0
- package/dist/commands/integrations/update/pushover.js +10 -0
- package/dist/commands/integrations/update/slack.js +10 -0
- package/dist/commands/integrations/update/splunk.js +10 -0
- package/dist/commands/integrations/update/telegram.js +10 -0
- package/dist/commands/integrations/update/webhook.js +10 -0
- package/dist/commands/integrations/update/zapier.js +10 -0
- package/dist/commands/monitors/create/third-party-service.js +10 -0
- package/dist/commands/monitors/create.js +10 -0
- package/dist/commands/monitors/storm-protection/update.js +10 -0
- package/dist/commands/monitors/storm-protection.js +10 -0
- package/dist/commands/monitors/third-party-service/incidents/activity-log.js +10 -0
- package/dist/commands/monitors/third-party-service/incidents/get.js +10 -0
- package/dist/commands/monitors/third-party-service/incidents/list.js +10 -0
- package/dist/commands/monitors/third-party-service/status.js +10 -0
- package/dist/commands/status-pages/announcements/delete.js +10 -0
- package/dist/commands/third-party-providers/get.js +10 -0
- package/dist/commands/third-party-providers/list.js +10 -0
- package/dist/commands/version.js +44 -0
- package/dist/generated/operations.js +10524 -4622
- package/dist/lib/cli-errors.js +21 -3
- package/dist/lib/command-suggestions.js +1 -0
- package/dist/lib/help-curation.js +25 -0
- package/dist/lib/operation-command.js +7 -3
- package/dist/lib/redact-secrets.js +16 -1
- package/dist/lib/request.js +41 -5
- package/dist/lib/response-curation.js +43 -0
- package/dist/output/columns.js +24 -0
- package/openapi/openapi.yaml +2924 -274
- package/package.json +16 -2
package/dist/lib/cli-errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { outputFormatFromEnvironment, requestedFormatFromArgv } from '../output/resolve-format.js';
|
|
2
2
|
import { agentEnvironmentDetected } from './invocation.js';
|
|
3
3
|
import { isRecord } from './objects.js';
|
|
4
|
+
import { redactSecrets } from './redact-secrets.js';
|
|
4
5
|
const handledErrors = new WeakSet();
|
|
5
6
|
export class CliFailure extends Error {
|
|
6
7
|
cliError;
|
|
@@ -11,9 +12,26 @@ export class CliFailure extends Error {
|
|
|
11
12
|
this.oclif = { exit: exitCode };
|
|
12
13
|
}
|
|
13
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Redacts the envelope rather than each error's source, because this is the one
|
|
17
|
+
* place a failure becomes output: it covers the API's `details` passthrough,
|
|
18
|
+
* the CLI's own bulk failures, and any error shape added later. The cast holds
|
|
19
|
+
* because only values are replaced and no CliError key is credential-like, so
|
|
20
|
+
* every key survives with its type.
|
|
21
|
+
*
|
|
22
|
+
* Unlike the success path this ignores --reveal-secrets. errorEnvelope also
|
|
23
|
+
* serves failures raised before flags are parsed, and failure output is what
|
|
24
|
+
* gets pasted into bug reports, CI logs, and agent transcripts.
|
|
25
|
+
*
|
|
26
|
+
* Redaction is key-based, so a secret interpolated into a free-text `message`
|
|
27
|
+
* is not covered; only structured content is.
|
|
28
|
+
*/
|
|
29
|
+
function redactError(error) {
|
|
30
|
+
return redactSecrets(error).value;
|
|
31
|
+
}
|
|
14
32
|
export function errorEnvelope(error) {
|
|
15
33
|
if (error instanceof CliFailure)
|
|
16
|
-
return { error: error.cliError };
|
|
34
|
+
return { error: redactError(error.cliError) };
|
|
17
35
|
const metadata = isRecord(error) ? error : {};
|
|
18
36
|
const exitCode = exitCodeForError(error);
|
|
19
37
|
const code = typeof metadata.code === 'string' && metadata.code !== 'EEXIT'
|
|
@@ -27,7 +45,7 @@ export function errorEnvelope(error) {
|
|
|
27
45
|
? error
|
|
28
46
|
: 'The command failed.';
|
|
29
47
|
return {
|
|
30
|
-
error: {
|
|
48
|
+
error: redactError({
|
|
31
49
|
code,
|
|
32
50
|
...(metadata.details !== undefined ? { details: metadata.details } : {}),
|
|
33
51
|
...(typeof metadata.expected === 'string' ? { expected: metadata.expected } : {}),
|
|
@@ -38,7 +56,7 @@ export function errorEnvelope(error) {
|
|
|
38
56
|
metadata.suggestions.every((suggestion) => typeof suggestion === 'string')
|
|
39
57
|
? { suggestions: metadata.suggestions }
|
|
40
58
|
: {}),
|
|
41
|
-
},
|
|
59
|
+
}),
|
|
42
60
|
};
|
|
43
61
|
}
|
|
44
62
|
export function exitCodeForError(error) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three bulk endpoints answer with a per-monitor `success` that reports
|
|
3
|
+
* acceptance, not application: there is no operation id to poll and nothing
|
|
4
|
+
* distinguishes queued from applied, so a caller treating exit 0 as "done" may
|
|
5
|
+
* act on monitors still in their old state. A stale-cache defect that made the
|
|
6
|
+
* gap tens of seconds wide was fixed in UPT-3557, so no duration is quoted —
|
|
7
|
+
* the absent completion signal is the part that is structural.
|
|
8
|
+
*/
|
|
9
|
+
const BULK_DELAY_NOTE = 'Reports acceptance, not completion. The API answers "success" for every matched monitor as soon as the write is accepted; it carries no operation id, and nothing in the response distinguishes queued from applied. Confirm with `uptimerobot monitors get <id>` before anything depends on the new state.';
|
|
10
|
+
/**
|
|
11
|
+
* Editorial notes appended to a command's help description. `src/generated` is
|
|
12
|
+
* a projection of openapi/openapi.yaml and is rewritten by
|
|
13
|
+
* `pnpm openapi:generate`, so behaviour the published contract omits belongs
|
|
14
|
+
* here, alongside the request curation in request-curation.ts.
|
|
15
|
+
*/
|
|
16
|
+
const DESCRIPTION_NOTES = {
|
|
17
|
+
'monitors:bulk:pause': BULK_DELAY_NOTE,
|
|
18
|
+
'monitors:bulk:start': BULK_DELAY_NOTE,
|
|
19
|
+
'monitors:bulk:update': BULK_DELAY_NOTE,
|
|
20
|
+
};
|
|
21
|
+
export function curatedCommandDescription(operation) {
|
|
22
|
+
const description = operation.description ?? operation.summary;
|
|
23
|
+
const note = DESCRIPTION_NOTES[operation.commandId];
|
|
24
|
+
return note === undefined ? description : `${description}\n\n${note}`;
|
|
25
|
+
}
|
|
@@ -13,6 +13,8 @@ import { InputValidationError, validateInput } from '../runtime/validate-input.j
|
|
|
13
13
|
import { AuthenticationError, resolveApiKey } from './auth.js';
|
|
14
14
|
import { BaseCommand } from './base-command.js';
|
|
15
15
|
import { flagName } from './flag-name.js';
|
|
16
|
+
import { curatedCommandDescription } from './help-curation.js';
|
|
17
|
+
import { withDerivedFields } from './response-curation.js';
|
|
16
18
|
import { detectInvocationMode, isCI, requestConfirmation } from './invocation.js';
|
|
17
19
|
import { enrichOperationParserError } from './operation-parser-error.js';
|
|
18
20
|
import { redactSecrets } from './redact-secrets.js';
|
|
@@ -57,6 +59,7 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
57
59
|
}
|
|
58
60
|
: {};
|
|
59
61
|
const defaultColumns = columnsFor(operation.commandId);
|
|
62
|
+
const description = curatedCommandDescription(operation);
|
|
60
63
|
const bodyFieldFlags = Object.fromEntries((operation.requestBodyFields ?? []).map((field) => [
|
|
61
64
|
field.flag,
|
|
62
65
|
flagForBodyField(curatedRequestBodyField(operation, field)),
|
|
@@ -102,10 +105,10 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
102
105
|
class OperationCommand extends BaseCommand {
|
|
103
106
|
static args = commandArgs;
|
|
104
107
|
static description = options.canonicalCommand
|
|
105
|
-
? `${
|
|
108
|
+
? `${description}
|
|
106
109
|
|
|
107
110
|
Canonical command: ${options.canonicalCommand}`
|
|
108
|
-
:
|
|
111
|
+
: description;
|
|
109
112
|
static examples = examplesFor(operation);
|
|
110
113
|
static flags = commandFlags;
|
|
111
114
|
static summary = operation.summary;
|
|
@@ -254,7 +257,8 @@ Canonical command: ${options.canonicalCommand}`
|
|
|
254
257
|
const apiError = normalizeApiError(response.status, payload);
|
|
255
258
|
return this.fail(apiError, exitCodeFor(response.status));
|
|
256
259
|
}
|
|
257
|
-
|
|
260
|
+
// --raw is the untouched API response, so derived fields stay out of it.
|
|
261
|
+
const normalized = flags.raw === true ? payload : withDerivedFields(normalizeResult(payload));
|
|
258
262
|
// Redacted before rendering, so column selection, truncation, and JSONL
|
|
259
263
|
// splitting all operate on the redacted values. --raw stays untouched.
|
|
260
264
|
const redaction = flags.raw === true || flags['reveal-secrets'] === true
|
|
@@ -17,15 +17,30 @@ function redactValue(value, path, redacted) {
|
|
|
17
17
|
}
|
|
18
18
|
if (!isRecord(value))
|
|
19
19
|
return value;
|
|
20
|
+
const contextual = contextualSensitiveKeys(value);
|
|
20
21
|
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
|
21
22
|
const childPath = [...path, key];
|
|
22
|
-
if (isSensitiveFieldName(key)) {
|
|
23
|
+
if (isSensitiveFieldName(key) || contextual.has(key)) {
|
|
23
24
|
redacted.push(formatPath(childPath));
|
|
24
25
|
return [key, REDACTED];
|
|
25
26
|
}
|
|
26
27
|
return [key, redactValue(child, childPath, redacted)];
|
|
27
28
|
}));
|
|
28
29
|
}
|
|
30
|
+
const NO_KEYS = new Set();
|
|
31
|
+
const HEARTBEAT_SECRET_KEYS = new Set(['pingUrl', 'url']);
|
|
32
|
+
/**
|
|
33
|
+
* Fields that carry a secret because of the record they sit in rather than what
|
|
34
|
+
* they are called. A heartbeat monitor's `url` is the token its ping endpoint
|
|
35
|
+
* is built from, and `pingUrl` is that endpoint: anyone holding either can
|
|
36
|
+
* report the heartbeat, so a leak keeps a dead service looking alive with no
|
|
37
|
+
* alert and nothing on the monitor to show for it. Every other monitor type
|
|
38
|
+
* uses `url` for a target address, which is not a secret — hence the type
|
|
39
|
+
* check rather than a name added to sensitive-fields.ts.
|
|
40
|
+
*/
|
|
41
|
+
function contextualSensitiveKeys(record) {
|
|
42
|
+
return record.type === 'HEARTBEAT' ? HEARTBEAT_SECRET_KEYS : NO_KEYS;
|
|
43
|
+
}
|
|
29
44
|
function formatPath(segments) {
|
|
30
45
|
return segments.reduce((path, segment) => /^\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment, '');
|
|
31
46
|
}
|
package/dist/lib/request.js
CHANGED
|
@@ -44,13 +44,17 @@ export async function buildRequestBody(operation, flags, headers) {
|
|
|
44
44
|
return undefined;
|
|
45
45
|
}
|
|
46
46
|
const objectBody = toObject(bodyValue);
|
|
47
|
+
const typedAssignments = new Map();
|
|
47
48
|
for (const field of bodyFields) {
|
|
48
49
|
const value = flags[field.flag];
|
|
49
|
-
if (value
|
|
50
|
-
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
const parsed = parseBodyField(field, value);
|
|
53
|
+
assignPath(objectBody, field.path, parsed);
|
|
54
|
+
typedAssignments.set(field.path, { flag: field.flag, value: parsed });
|
|
51
55
|
}
|
|
52
56
|
for (const assignment of assignments)
|
|
53
|
-
assignValue(objectBody, assignment);
|
|
57
|
+
assignValue(objectBody, assignment, typedAssignments);
|
|
54
58
|
for (const [path, value] of Object.entries(operation.requestBodyDefaults ?? {})) {
|
|
55
59
|
const supplied = valueAtPath(objectBody, path);
|
|
56
60
|
if (supplied !== undefined && supplied !== value) {
|
|
@@ -111,9 +115,41 @@ function toObject(value) {
|
|
|
111
115
|
}
|
|
112
116
|
return structuredClone(value);
|
|
113
117
|
}
|
|
114
|
-
function assignValue(target, assignment) {
|
|
118
|
+
function assignValue(target, assignment, typedAssignments) {
|
|
115
119
|
const [path, rawValue] = splitAssignment(assignment, '--set');
|
|
116
|
-
|
|
120
|
+
const value = parseValue(rawValue);
|
|
121
|
+
// --body is a base that both typed flags and --set layer onto, but those two
|
|
122
|
+
// are peers: when they name the same field nothing says which one was meant,
|
|
123
|
+
// so the old last-one-wins silently discarded the typed flag.
|
|
124
|
+
const typed = typedAssignments.get(path);
|
|
125
|
+
if (typed !== undefined && !sameValue(typed.value, value)) {
|
|
126
|
+
throw new RequestInputError(path, 'a single value', `${path} received conflicting values: --${typed.flag} ${describeValue(typed.value)} and --set ${path}=${describeValue(value)}. Pass only one.`);
|
|
127
|
+
}
|
|
128
|
+
assignPath(target, path, value);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Structural equality, so the same object written two ways is not a conflict:
|
|
132
|
+
* a typed flag carrying `{"a":1,"b":2}` and `--set` carrying `{"b":2,"a":1}`
|
|
133
|
+
* say the same thing, and refusing them would be a false positive on the very
|
|
134
|
+
* case the conflict check is meant to allow through.
|
|
135
|
+
*/
|
|
136
|
+
function sameValue(left, right) {
|
|
137
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
138
|
+
}
|
|
139
|
+
function canonicalJson(value) {
|
|
140
|
+
return JSON.stringify(withSortedKeys(value ?? null));
|
|
141
|
+
}
|
|
142
|
+
function withSortedKeys(value) {
|
|
143
|
+
if (Array.isArray(value))
|
|
144
|
+
return value.map(withSortedKeys);
|
|
145
|
+
if (!isPlainRecord(value))
|
|
146
|
+
return value;
|
|
147
|
+
return Object.fromEntries(Object.keys(value)
|
|
148
|
+
.sort()
|
|
149
|
+
.map((key) => [key, withSortedKeys(value[key])]));
|
|
150
|
+
}
|
|
151
|
+
function describeValue(value) {
|
|
152
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
117
153
|
}
|
|
118
154
|
function splitAssignment(value, flag) {
|
|
119
155
|
const separator = value.indexOf('=');
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isPlainRecord } from './objects.js';
|
|
2
|
+
/**
|
|
3
|
+
* The heartbeat host the ping URL is built against. Overridable because the
|
|
4
|
+
* host is environment-specific and is not part of the published API contract.
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_HEARTBEAT_HOST = 'https://heartbeat.uptimerobot.com';
|
|
7
|
+
/**
|
|
8
|
+
* A heartbeat monitor is inert until something pings its URL, and the API
|
|
9
|
+
* returns the ingredients without ever naming the result: `url` carries the
|
|
10
|
+
* token and the ping endpoint is `<host>/m<id>-<token>`. That composition is
|
|
11
|
+
* how the dashboard builds the URL it shows, but it is documented nowhere, so
|
|
12
|
+
* an API consumer cannot discover it. Deriving it here lets a heartbeat be
|
|
13
|
+
* created and wired up without a trip to the dashboard.
|
|
14
|
+
*
|
|
15
|
+
* Derived rather than authoritative: UPT-3726 asks the API to return the URL
|
|
16
|
+
* itself, and this should give way to that field once it exists.
|
|
17
|
+
*/
|
|
18
|
+
export function withDerivedFields(payload, env = process.env) {
|
|
19
|
+
if (Array.isArray(payload))
|
|
20
|
+
return payload.map((item) => withDerivedFields(item, env));
|
|
21
|
+
if (!isPlainRecord(payload))
|
|
22
|
+
return payload;
|
|
23
|
+
if (Array.isArray(payload.items)) {
|
|
24
|
+
return { ...payload, items: payload.items.map((item) => withDerivedFields(item, env)) };
|
|
25
|
+
}
|
|
26
|
+
// An API-supplied value wins: this composition exists only while the contract
|
|
27
|
+
// has none, and must not shadow the real field when UPT-3726 lands.
|
|
28
|
+
if (typeof payload.pingUrl === 'string')
|
|
29
|
+
return payload;
|
|
30
|
+
const pingUrl = heartbeatPingUrl(payload, env);
|
|
31
|
+
return pingUrl === undefined ? payload : { ...payload, pingUrl };
|
|
32
|
+
}
|
|
33
|
+
function heartbeatPingUrl(monitor, env) {
|
|
34
|
+
if (monitor.type !== 'HEARTBEAT')
|
|
35
|
+
return undefined;
|
|
36
|
+
const { id, url } = monitor;
|
|
37
|
+
if (typeof url !== 'string' || url.length === 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
if (typeof id !== 'number' && typeof id !== 'string')
|
|
40
|
+
return undefined;
|
|
41
|
+
const host = (env.UPTIMEROBOT_HEARTBEAT_URL ?? DEFAULT_HEARTBEAT_HOST).replace(/\/+$/, '');
|
|
42
|
+
return `${host}/m${id}-${url}`;
|
|
43
|
+
}
|
package/dist/output/columns.js
CHANGED
|
@@ -9,9 +9,12 @@ export const COLLECTION_COMMAND_IDS = [
|
|
|
9
9
|
'maintenance-windows:list',
|
|
10
10
|
'monitor-groups:list',
|
|
11
11
|
'monitors:list',
|
|
12
|
+
'monitors:third-party-service:incidents:activity-log',
|
|
13
|
+
'monitors:third-party-service:incidents:list',
|
|
12
14
|
'status-pages:announcements:list',
|
|
13
15
|
'status-pages:list',
|
|
14
16
|
'tags:list',
|
|
17
|
+
'third-party-providers:list',
|
|
15
18
|
'user:alert-contacts',
|
|
16
19
|
'user:all-alert-contacts',
|
|
17
20
|
];
|
|
@@ -87,6 +90,20 @@ export const COMMAND_COLUMNS = {
|
|
|
87
90
|
{ format: stateAge, header: 'IN STATE', key: 'currentStateDuration' },
|
|
88
91
|
{ format: joinNames, key: 'tags', maxWidth: 32 },
|
|
89
92
|
],
|
|
93
|
+
'monitors:third-party-service:incidents:activity-log': [
|
|
94
|
+
{ key: 'timestamp' },
|
|
95
|
+
{ key: 'status' },
|
|
96
|
+
{ header: 'RECIPIENT', key: 'alertContactName', maxWidth: 28 },
|
|
97
|
+
{ header: 'CONTACT', key: 'channelIdentifier', maxWidth: 36 },
|
|
98
|
+
{ key: 'channel' },
|
|
99
|
+
],
|
|
100
|
+
'monitors:third-party-service:incidents:list': [
|
|
101
|
+
{ key: 'id' },
|
|
102
|
+
{ key: 'severity' },
|
|
103
|
+
{ key: 'title', maxWidth: 48 },
|
|
104
|
+
{ header: 'STARTED', key: 'startedAt' },
|
|
105
|
+
{ header: 'RESOLVED', key: 'resolvedAt' },
|
|
106
|
+
],
|
|
90
107
|
'status-pages:announcements:list': [
|
|
91
108
|
{ key: 'id' },
|
|
92
109
|
{ key: 'title', maxWidth: 40 },
|
|
@@ -106,6 +123,13 @@ export const COMMAND_COLUMNS = {
|
|
|
106
123
|
{ format: accessLevel, header: 'ACCESS', key: 'isPasswordSet' },
|
|
107
124
|
],
|
|
108
125
|
'tags:list': [{ key: 'id' }, { key: 'name', maxWidth: 28 }],
|
|
126
|
+
'third-party-providers:list': [
|
|
127
|
+
{ key: 'id' },
|
|
128
|
+
{ key: 'name', maxWidth: 28 },
|
|
129
|
+
{ key: 'state' },
|
|
130
|
+
{ header: 'TRACKED', key: 'isTracked' },
|
|
131
|
+
{ header: 'STATUS PAGE', key: 'statusPageUrl', maxWidth: 40 },
|
|
132
|
+
],
|
|
109
133
|
'user:alert-contacts': ALERT_CONTACT_COLUMNS,
|
|
110
134
|
'user:all-alert-contacts': [
|
|
111
135
|
{ header: 'USER ID', key: 'user.id' },
|