@uptimerobot/cli 0.2.0 → 1.0.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 +83 -8
- package/bin/run.js +32 -10
- package/dist/api/errors.js +20 -1
- package/dist/commands/help.js +15 -3
- package/dist/commands/skills/install.js +38 -0
- package/dist/commands/version.js +44 -0
- package/dist/generated/operations.js +18 -0
- package/dist/lib/base-command.js +7 -0
- package/dist/lib/command-id.js +50 -0
- package/dist/lib/command-suggestions.js +2 -0
- package/dist/lib/form-body.js +55 -0
- package/dist/lib/help-curation.js +25 -0
- package/dist/lib/invocation.js +6 -3
- package/dist/lib/operation-command.js +43 -14
- package/dist/lib/redact-secrets.js +46 -0
- package/dist/lib/request-input-error.js +16 -0
- package/dist/lib/request-preview.js +4 -23
- package/dist/lib/request.js +55 -38
- package/dist/lib/response-curation.js +43 -0
- package/dist/lib/skills-installer.js +19 -0
- package/dist/output/bulk-failure.js +42 -0
- package/openapi/openapi.yaml +16 -0
- package/package.json +14 -3
package/README.md
CHANGED
|
@@ -36,6 +36,23 @@ uptimerobot --version
|
|
|
36
36
|
uptimerobot --help
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
`--version` prints one human-readable line. The `version` command reports the same facts through the normal output flags, so a script asserting a minimum version reads a field instead of parsing a sentence:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
uptimerobot version --json
|
|
43
|
+
# {"version":"1.0.0","platform":"darwin-arm64","node":"24.12.0"}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## AI agent skills
|
|
47
|
+
|
|
48
|
+
Install the [UptimeRobot AI skills](https://github.com/uptimerobot/ai) for supported coding agents:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
uptimerobot skills install
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The CLI shows the exact external command and requires confirmation before handing the terminal to `npx skills`. `npx` may download and execute the third-party `skills` package, which can write skill files into your project or agent directories. The installer prompts for the skills, agents, and installation scope. In a non-interactive environment, run `npx skills add uptimerobot/ai` directly after reviewing it.
|
|
55
|
+
|
|
39
56
|
## Authentication
|
|
40
57
|
|
|
41
58
|
[Create an API key](https://help.uptimerobot.com/en/articles/11620152-how-to-use-uptimerobot-s-api), then save it in your operating system's secure credential store:
|
|
@@ -101,6 +118,8 @@ uptimerobot monitors create keyword --help
|
|
|
101
118
|
uptimerobot incidents comments create --help
|
|
102
119
|
```
|
|
103
120
|
|
|
121
|
+
`uptimerobot help <command-or-topic>` is equivalent to the `--help` flag, so `uptimerobot help monitors create` and `uptimerobot monitors create --help` print the same page.
|
|
122
|
+
|
|
104
123
|
Monitor creation commands expose only the fields relevant to their type and supply the API monitor type automatically. Command help includes accepted values, required fields, defaults, constraints, and examples from the packaged API contract.
|
|
105
124
|
|
|
106
125
|
Typed flags cover normal use. Full API request bodies remain available when needed:
|
|
@@ -111,6 +130,30 @@ uptimerobot monitors update 797054213 --set interval=120
|
|
|
111
130
|
uptimerobot monitors create http --body - < monitor.json
|
|
112
131
|
```
|
|
113
132
|
|
|
133
|
+
`--body` is a base document, and typed flags and `--set` are applied on top of it. Those two are peers, so giving the same field a different value with both is an error rather than a silent win for one of them:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
uptimerobot monitors create http --name checkout-api --set friendlyName=checkout
|
|
137
|
+
# INVALID_INPUT: friendlyName received conflicting values:
|
|
138
|
+
# --name checkout-api and --set friendlyName=checkout. Pass only one.
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Heartbeat monitors
|
|
142
|
+
|
|
143
|
+
A heartbeat monitor stays silent until something pings it, so normalized output carries a derived `pingUrl` — the address that job should call. Both it and the `url` it is built from are secrets, so both are redacted unless you ask for them:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
uptimerobot monitors get 803704830 --json
|
|
147
|
+
# {"type":"HEARTBEAT","id":803704830,"url":"[REDACTED]","pingUrl":"[REDACTED]"}
|
|
148
|
+
|
|
149
|
+
uptimerobot monitors get 803704830 --json --reveal-secrets
|
|
150
|
+
# {"type":"HEARTBEAT","id":803704830,"url":"997c8970…","pingUrl":"https://heartbeat.uptimerobot.com/m803704830-997c8970…"}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Reach for `--reveal-secrets` when wiring up the job that pings it, which is normally once per monitor. Anyone holding the ping URL can report the heartbeat on your behalf, so a leaked one keeps a dead service looking alive with no alert raised. `url` on every other monitor type is a target address, not a secret, and is never redacted.
|
|
154
|
+
|
|
155
|
+
The URL is composed from the monitor's `id` and `url`, where `url` carries the monitor's token rather than a target address. Set `UPTIMEROBOT_HEARTBEAT_URL` to point at a different host. The field is derived by the CLI, not returned by the API, so `--raw` does not include it.
|
|
156
|
+
|
|
114
157
|
## Validate before sending
|
|
115
158
|
|
|
116
159
|
Inspect a monitor request contract or print a validated example without credentials or network access:
|
|
@@ -132,19 +175,51 @@ Dry-run output contains the final method, path, content type, and body. Recogniz
|
|
|
132
175
|
|
|
133
176
|
Interactive terminals use tables by default. Piped, redirected, and agent-driven commands use normalized JSON by default.
|
|
134
177
|
|
|
135
|
-
| Option
|
|
136
|
-
|
|
|
137
|
-
| `--json`
|
|
138
|
-
| `--format jsonl`
|
|
139
|
-
| `--format table`
|
|
140
|
-
| `--format plain`
|
|
141
|
-
| `--raw`
|
|
178
|
+
| Option | Output |
|
|
179
|
+
| ------------------ | ------------------------------------ |
|
|
180
|
+
| `--json` | Normalized JSON |
|
|
181
|
+
| `--format jsonl` | One normalized resource per line |
|
|
182
|
+
| `--format table` | Human-readable table |
|
|
183
|
+
| `--format plain` | Headerless, tab-separated rows |
|
|
184
|
+
| `--raw` | Untouched API response as JSON |
|
|
185
|
+
| `--reveal-secrets` | Credential-like fields shown in full |
|
|
186
|
+
|
|
187
|
+
Recognizable credential fields in a response, such as `httpPassword`, print as `[REDACTED]` in every format, and a stderr notice names what was hidden. Pass `--reveal-secrets` to print the values instead. `--raw` skips redaction along with the rest of normalization.
|
|
142
188
|
|
|
143
189
|
Set `UPTIMEROBOT_OUTPUT` to `json`, `jsonl`, `table`, or `plain` to choose a default. Use `--agent` or `UPTIMEROBOT_AGENT=1` to identify agent execution explicitly. A truthy `CI` environment variable identifies CI execution independently.
|
|
144
190
|
|
|
145
191
|
Normalized collection JSON contains `items` and `nextCursor`. The CLI does not follow pagination automatically; pass a returned cursor through `--cursor` for the next page. Collection commands also support `--columns` and `--all`. Column selection never changes JSON, JSONL, or raw output, and `--all` may expose sensitive API fields.
|
|
146
192
|
|
|
147
|
-
Machine-readable failures are emitted as JSON on stderr
|
|
193
|
+
Machine-readable failures are emitted as JSON on stderr. Stdout remains empty, except for `monitors bulk` commands, which print their per-item results on stdout even when the operation failed.
|
|
194
|
+
|
|
195
|
+
The exit code identifies the failure:
|
|
196
|
+
|
|
197
|
+
| Exit code | Meaning |
|
|
198
|
+
| --------- | ----------------------------------------------------------------------------------- |
|
|
199
|
+
| `0` | Success |
|
|
200
|
+
| `1` | Generic failure, including a bulk operation where every item failed (`BULK_FAILED`) |
|
|
201
|
+
| `2` | Invalid input, rejected either locally or by the API (HTTP 400 or 422) |
|
|
202
|
+
| `3` | Bulk operation where some items failed (`BULK_PARTIAL_FAILURE`) |
|
|
203
|
+
| `4` | Unauthenticated (HTTP 401) |
|
|
204
|
+
| `5` | Forbidden (HTTP 403) |
|
|
205
|
+
| `6` | Not found (HTTP 404) |
|
|
206
|
+
| `7` | Rate limited (HTTP 429) |
|
|
207
|
+
| `8` | Conflict with the current state (HTTP 409) |
|
|
208
|
+
|
|
209
|
+
Exit code `2` covers both a request the CLI rejected before sending and one the API rejected as invalid, so it always means the input is wrong. Along with `8`, it says the request will not succeed as sent and should be changed rather than repeated; `1` and `7` are the failures a plain retry may clear.
|
|
210
|
+
|
|
211
|
+
The error envelope's `code` is `HTTP_<status>` whenever `status` is present, and a CLI symbol such as `INVALID_INPUT` or `AUTH_REQUIRED` otherwise, so `status` distinguishes a failure the API reported from one the CLI raised. When the API supplies an error code of its own it is passed through verbatim as `apiCode` and is never interpreted; it is not part of the CLI's contract, because the API does not currently document those codes:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
uptimerobot monitors get 999999999 --json
|
|
215
|
+
# {"error":{"apiCode":"000-004","code":"HTTP_404","message":"Monitor not found","status":404}}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Branch on `code` or `status`. Read `apiCode` only for diagnostics, and use `--raw` for the untouched API response.
|
|
219
|
+
|
|
220
|
+
`monitors bulk pause`, `monitors bulk start`, and `monitors bulk update` report per-monitor outcomes in an HTTP 201 response, so a failure is only visible in the body. The CLI inspects it and exits non-zero, listing the failed entries in the error envelope's `details`.
|
|
221
|
+
|
|
222
|
+
Those three commands report acceptance rather than 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. Exit code `0` therefore means the request was accepted, not that the monitors have changed. A pipeline that pauses monitors before a deploy should confirm with `uptimerobot monitors get <id>` before it continues.
|
|
148
223
|
|
|
149
224
|
## Safety
|
|
150
225
|
|
package/bin/run.js
CHANGED
|
@@ -13,24 +13,46 @@ if (!nodeVersionSupported) {
|
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Resolves when a reader closes stdout before the CLI is done writing to it,
|
|
18
|
+
* which is what `| head`, `| head -c`, and `| grep -q` do. Every later write
|
|
19
|
+
* then fails with EPIPE. Without this listener Node reports the stream error
|
|
20
|
+
* as unhandled; with it, the broken pipe is simply the end of the output.
|
|
21
|
+
*/
|
|
22
|
+
const stdoutClosedEarly = new Promise((resolve) => {
|
|
23
|
+
process.stdout.on('error', (error) => {
|
|
24
|
+
if (error.code === 'EPIPE') resolve();
|
|
25
|
+
else throw error;
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
16
29
|
const { flush, handle, run } = await import('@oclif/core');
|
|
17
30
|
const { commandSuggestion } = await import('../dist/lib/command-suggestions.js');
|
|
31
|
+
const { isUnknownCommandMessage, withConfiguredCommandId } =
|
|
32
|
+
await import('../dist/lib/command-id.js');
|
|
18
33
|
const { errorEnvelope, exitCodeForError, machineOutputRequested, wasErrorHandled } =
|
|
19
34
|
await import('../dist/lib/cli-errors.js');
|
|
20
35
|
|
|
36
|
+
/**
|
|
37
|
+
* oclif's flush() waits for a `drain` that a broken pipe never emits, so on
|
|
38
|
+
* EPIPE it never settles: Node then printed `Detected unsettled top-level
|
|
39
|
+
* await` on stderr and exited 13. Racing the two keeps stderr meaningful and
|
|
40
|
+
* lets the normal exit path decide the status, which is 0 for a reader that
|
|
41
|
+
* stopped listening and unchanged for a command that had already failed.
|
|
42
|
+
*/
|
|
43
|
+
const flushOutput = () => Promise.race([flush(), stdoutClosedEarly]);
|
|
44
|
+
|
|
21
45
|
try {
|
|
22
46
|
await run(process.argv.slice(2), import.meta.url);
|
|
23
|
-
await
|
|
47
|
+
await flushOutput();
|
|
24
48
|
} catch (error) {
|
|
25
|
-
await
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
) {
|
|
33
|
-
error.suggestions = [`uptimerobot ${suggestion}`];
|
|
49
|
+
await flushOutput();
|
|
50
|
+
if (typeof error === 'object' && error !== null && isUnknownCommandMessage(error.message ?? '')) {
|
|
51
|
+
// oclif names the command with its colon-joined internal id; report it the
|
|
52
|
+
// way the CLI accepts it, so the message is a syntax the user can retype.
|
|
53
|
+
error.message = withConfiguredCommandId(error.message);
|
|
54
|
+
const suggestion = commandSuggestion(process.argv.slice(2));
|
|
55
|
+
if (suggestion) error.suggestions = [`uptimerobot ${suggestion}`];
|
|
34
56
|
}
|
|
35
57
|
if (wasErrorHandled(error)) {
|
|
36
58
|
process.exitCode = exitCodeForError(error);
|
package/dist/api/errors.js
CHANGED
|
@@ -7,19 +7,38 @@ export function normalizeApiError(status, payload) {
|
|
|
7
7
|
? rawMessage
|
|
8
8
|
: `UptimeRobot API request failed with HTTP ${status}.`;
|
|
9
9
|
return {
|
|
10
|
-
|
|
10
|
+
// Derived from the transport status, never from the body: an upstream code
|
|
11
|
+
// is optional, undocumented, and absent from openapi.yaml, so reporting it
|
|
12
|
+
// as `code` left the vocabulary depending on which endpoint answered. The
|
|
13
|
+
// raw value is kept beside it, verbatim and uninterpreted.
|
|
14
|
+
...(typeof object.code === 'string' ? { apiCode: object.code } : {}),
|
|
15
|
+
code: `HTTP_${status}`,
|
|
11
16
|
...(object.details !== undefined ? { details: object.details } : {}),
|
|
12
17
|
message,
|
|
13
18
|
status,
|
|
14
19
|
};
|
|
15
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Maps an HTTP status onto the CLI's exit code contract. A request the server
|
|
23
|
+
* rejected as invalid exits 2, the same code locally caught invalid input
|
|
24
|
+
* produces, so a caller can tell "the request was wrong, do not retry it" from
|
|
25
|
+
* a generic failure that retrying may clear. Conflicts get their own code
|
|
26
|
+
* because the request is well formed and only the current server state refuses
|
|
27
|
+
* it, which a caller resolves differently.
|
|
28
|
+
*/
|
|
16
29
|
export function exitCodeFor(status) {
|
|
30
|
+
if (status === 400)
|
|
31
|
+
return 2;
|
|
17
32
|
if (status === 401)
|
|
18
33
|
return 4;
|
|
19
34
|
if (status === 403)
|
|
20
35
|
return 5;
|
|
21
36
|
if (status === 404)
|
|
22
37
|
return 6;
|
|
38
|
+
if (status === 409)
|
|
39
|
+
return 8;
|
|
40
|
+
if (status === 422)
|
|
41
|
+
return 2;
|
|
23
42
|
if (status === 429)
|
|
24
43
|
return 7;
|
|
25
44
|
return 1;
|
package/dist/commands/help.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
|
+
import { Args } from '@oclif/core';
|
|
1
2
|
import { loadHelpClass } from '@oclif/core/help';
|
|
2
3
|
import { BaseCommand } from '../lib/base-command.js';
|
|
3
4
|
export default class Help extends BaseCommand {
|
|
4
|
-
|
|
5
|
+
// A topic is spelled with spaces (`help monitors bulk`), so the subject is
|
|
6
|
+
// variadic. Declaring an argument at all is what stops oclif from folding the
|
|
7
|
+
// words into the command id, which is what made `help monitors` fail as
|
|
8
|
+
// `command help:monitors not found`.
|
|
9
|
+
static args = {
|
|
10
|
+
command: Args.string({
|
|
11
|
+
description: 'Command or topic to describe; omit for the command overview',
|
|
12
|
+
multiple: true,
|
|
13
|
+
required: false,
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
static description = 'With no argument, print the command overview, identical to running uptimerobot --help';
|
|
5
17
|
static summary = 'Display help for uptimerobot';
|
|
6
18
|
async run() {
|
|
7
|
-
await this.parse(Help);
|
|
19
|
+
const { args } = await this.parse(Help);
|
|
8
20
|
// Loaded the way --help loads it, so both paths honour a custom help class
|
|
9
21
|
// and render the same output.
|
|
10
22
|
const HelpClass = await loadHelpClass(this.config);
|
|
11
23
|
const help = new HelpClass(this.config, this.config.pjson.oclif.helpOptions);
|
|
12
|
-
await help.showHelp([]);
|
|
24
|
+
await help.showHelp(args.command ?? []);
|
|
13
25
|
}
|
|
14
26
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BaseCommand } from '../../lib/base-command.js';
|
|
2
|
+
import { confirmationAvailable, detectInvocationMode, requestConfirmation, } from '../../lib/invocation.js';
|
|
3
|
+
import { runSkillsInstaller, SKILLS_INSTALL_COMMAND } from '../../lib/skills-installer.js';
|
|
4
|
+
export default class SkillsInstall extends BaseCommand {
|
|
5
|
+
static description = `Run the external skills installer for the UptimeRobot AI skill collection. After confirmation, ${SKILLS_INSTALL_COMMAND} controls the terminal and prompts for skills, agents, and installation scope.`;
|
|
6
|
+
static summary = 'Install UptimeRobot AI skills';
|
|
7
|
+
async run() {
|
|
8
|
+
await this.parse(SkillsInstall);
|
|
9
|
+
const mode = detectInvocationMode(false);
|
|
10
|
+
if (!confirmationAvailable(mode)) {
|
|
11
|
+
return this.fail({
|
|
12
|
+
code: 'CONFIRMATION_REQUIRED',
|
|
13
|
+
message: `Interactive confirmation is required. Run ${SKILLS_INSTALL_COMMAND} directly in a terminal.`,
|
|
14
|
+
}, 2);
|
|
15
|
+
}
|
|
16
|
+
const confirmed = await requestConfirmation(`Run ${SKILLS_INSTALL_COMMAND}`, mode);
|
|
17
|
+
if (!confirmed) {
|
|
18
|
+
this.logToStderr('Installation cancelled.');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const exitCode = await runSkillsInstaller();
|
|
23
|
+
if (exitCode !== 0)
|
|
24
|
+
process.exitCode = exitCode;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error instanceof Error &&
|
|
28
|
+
'code' in error &&
|
|
29
|
+
error.code === 'ENOENT') {
|
|
30
|
+
return this.fail({
|
|
31
|
+
code: 'INSTALLER_NOT_FOUND',
|
|
32
|
+
message: 'Could not start npx. Install npm and ensure npx is available on PATH.',
|
|
33
|
+
}, 1);
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core';
|
|
2
|
+
import { BaseCommand } from '../lib/base-command.js';
|
|
3
|
+
import { detectInvocationMode } from '../lib/invocation.js';
|
|
4
|
+
import { formatOutput } from '../output/renderer.js';
|
|
5
|
+
import { resolveFormat } from '../output/resolve-format.js';
|
|
6
|
+
export default class Version extends BaseCommand {
|
|
7
|
+
// The bare --version flag is handled by oclif core before any command runs,
|
|
8
|
+
// so it always prints one human sentence. This command reports the same
|
|
9
|
+
// facts as separate fields, which is what a version assertion in CI needs.
|
|
10
|
+
static description = 'Report the CLI version, the platform it runs on, and the Node.js runtime executing it. Unlike the --version flag, this command honours the output flags, so a caller can read the version as a field instead of parsing a sentence.';
|
|
11
|
+
static examples = ['<%= config.bin %> version --json'];
|
|
12
|
+
static flags = {
|
|
13
|
+
format: Flags.string({
|
|
14
|
+
description: 'Output format; json is normalized and jsonl emits one resource per line',
|
|
15
|
+
options: ['json', 'jsonl', 'table', 'plain'],
|
|
16
|
+
}),
|
|
17
|
+
json: Flags.boolean({
|
|
18
|
+
description: 'Shorthand for --format json; emit normalized structured JSON',
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
static summary = 'Show the CLI version';
|
|
22
|
+
async run() {
|
|
23
|
+
const { flags } = await this.parse(Version);
|
|
24
|
+
if (flags.json === true && flags.format !== undefined) {
|
|
25
|
+
return this.fail({ code: 'INVALID_INPUT', message: '--json cannot be combined with --format.' }, 2);
|
|
26
|
+
}
|
|
27
|
+
const info = {
|
|
28
|
+
version: this.config.version,
|
|
29
|
+
platform: `${this.config.platform}-${this.config.arch}`,
|
|
30
|
+
node: process.versions.node,
|
|
31
|
+
};
|
|
32
|
+
const format = resolveFormat(flags, detectInvocationMode(false));
|
|
33
|
+
if (format === 'json' || format === 'jsonl') {
|
|
34
|
+
this.log(JSON.stringify(info));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// A single record has no collection to render, so it is passed as a
|
|
38
|
+
// one-row collection to get the table headings and the tab-separated
|
|
39
|
+
// plain row every other command produces.
|
|
40
|
+
const output = formatOutput(format, [info]);
|
|
41
|
+
if (output !== undefined)
|
|
42
|
+
this.log(output);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -354,6 +354,15 @@ export const operations = {
|
|
|
354
354
|
"example": "Investigating the issue...",
|
|
355
355
|
"maxLength": 10000,
|
|
356
356
|
"type": "string"
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"flag": "publish-on-status-page",
|
|
360
|
+
"path": "publishOnStatusPage",
|
|
361
|
+
"required": false,
|
|
362
|
+
"default": false,
|
|
363
|
+
"description": "Whether the comment is published on the status page",
|
|
364
|
+
"example": true,
|
|
365
|
+
"type": "boolean"
|
|
357
366
|
}
|
|
358
367
|
]
|
|
359
368
|
},
|
|
@@ -472,6 +481,15 @@ export const operations = {
|
|
|
472
481
|
"example": "Issue has been identified and fix is in progress...",
|
|
473
482
|
"maxLength": 10000,
|
|
474
483
|
"type": "string"
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
"flag": "publish-on-status-page",
|
|
487
|
+
"path": "publishOnStatusPage",
|
|
488
|
+
"required": false,
|
|
489
|
+
"default": false,
|
|
490
|
+
"description": "Whether the comment is published on the status page",
|
|
491
|
+
"example": true,
|
|
492
|
+
"type": "boolean"
|
|
475
493
|
}
|
|
476
494
|
]
|
|
477
495
|
},
|
package/dist/lib/base-command.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Command } from '@oclif/core';
|
|
2
2
|
import { CliFailure, errorEnvelope, exitCodeForError, machineOutputRequested, markErrorHandled, } from './cli-errors.js';
|
|
3
|
+
import { isUnknownCommandMessage, withConfiguredCommandId } from './command-id.js';
|
|
3
4
|
export class BaseCommand extends Command {
|
|
4
5
|
async catch(error) {
|
|
6
|
+
// The other boundary that turns an error into output. `help <unknown topic>`
|
|
7
|
+
// raises its unknown-command error inside the command, so it never reaches
|
|
8
|
+
// the rewrite in bin/run.js and would keep oclif's colon-joined id.
|
|
9
|
+
if (isUnknownCommandMessage(error.message)) {
|
|
10
|
+
error.message = withConfiguredCommandId(error.message);
|
|
11
|
+
}
|
|
5
12
|
const failure = error instanceof CliFailure;
|
|
6
13
|
const machineOutput = machineOutputRequested(this.argv);
|
|
7
14
|
if (!failure && !machineOutput) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isPlainRecord } from './objects.js';
|
|
3
|
+
/**
|
|
4
|
+
* oclif builds command ids by joining topics with a colon and reports an
|
|
5
|
+
* unknown command with that internal spelling, so `uptimerobot monitors lst`
|
|
6
|
+
* fails with `command monitors:lst not found`. This CLI sets
|
|
7
|
+
* `oclif.topicSeparator` to a space, so the colon form is a syntax it does not
|
|
8
|
+
* accept, and echoing it back teaches the wrong thing.
|
|
9
|
+
*
|
|
10
|
+
* Two spellings reach the user:
|
|
11
|
+
*
|
|
12
|
+
* - `command <id> not found` — Config#runCommand, for an unroutable argv
|
|
13
|
+
* - `Command <id> not found.` — Help#showHelp, for an unknown help subject
|
|
14
|
+
*
|
|
15
|
+
* Only the id is rewritten. Replacing every colon in the message would also
|
|
16
|
+
* rewrite colons that belong to the prose, such as a URL or a `min: 5` bound,
|
|
17
|
+
* so the id is captured on its own and substituted in place. The capture is
|
|
18
|
+
* narrowed to the characters a command id can contain, which keeps a
|
|
19
|
+
* URL-shaped argument intact. A message that is not one of the two forms is
|
|
20
|
+
* returned untouched.
|
|
21
|
+
*/
|
|
22
|
+
const UNKNOWN_COMMAND = /^([Cc]ommand )([\w:-]+)( not found\.?)$/;
|
|
23
|
+
export function isUnknownCommandMessage(message) {
|
|
24
|
+
return UNKNOWN_COMMAND.test(message);
|
|
25
|
+
}
|
|
26
|
+
export function withConfiguredCommandId(message) {
|
|
27
|
+
return message.replace(UNKNOWN_COMMAND, (_full, prefix, id, suffix) => `${prefix}${id.replaceAll(':', topicSeparator())}${suffix}`);
|
|
28
|
+
}
|
|
29
|
+
let cachedSeparator;
|
|
30
|
+
/**
|
|
31
|
+
* The `oclif.topicSeparator` the CLI is configured with, read from the same
|
|
32
|
+
* manifest oclif itself reads so the two can never disagree. Falls back to
|
|
33
|
+
* oclif's own default, which leaves the id unchanged.
|
|
34
|
+
*/
|
|
35
|
+
function topicSeparator() {
|
|
36
|
+
if (cachedSeparator !== undefined)
|
|
37
|
+
return cachedSeparator;
|
|
38
|
+
cachedSeparator = ':';
|
|
39
|
+
try {
|
|
40
|
+
const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
41
|
+
const oclif = isPlainRecord(manifest) ? manifest.oclif : undefined;
|
|
42
|
+
const separator = isPlainRecord(oclif) ? oclif.topicSeparator : undefined;
|
|
43
|
+
if (typeof separator === 'string' && separator.length > 0)
|
|
44
|
+
cachedSeparator = separator;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// An unreadable manifest must not replace the error being reported.
|
|
48
|
+
}
|
|
49
|
+
return cachedSeparator;
|
|
50
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The multipart encoder, used only when a request carries a `--file` upload.
|
|
3
|
+
* Every other request is sent as JSON, which preserves types, nesting, and
|
|
4
|
+
* empty arrays that multipart cannot express.
|
|
5
|
+
*
|
|
6
|
+
* The API decodes multipart bodies with multer, whose `append-field` parser
|
|
7
|
+
* dictates each rule below. Keeping all of them here means there is a single
|
|
8
|
+
* place to change if the API ever documents a different wire format.
|
|
9
|
+
*
|
|
10
|
+
* - Arrays are appended once per item under a `key[]` name. The bracket
|
|
11
|
+
* suffix makes the parser build an array even for one item; a bare
|
|
12
|
+
* repeated `key` only becomes an array from the second occurrence onwards,
|
|
13
|
+
* so a single `monitorIds` would arrive as a string and be rejected.
|
|
14
|
+
* - Nested objects are flattened to dotted paths (`customSettings.page.theme`)
|
|
15
|
+
* rather than bracketed ones. A dotted name stays a literal key on the
|
|
16
|
+
* request body and is reassembled by the API; bracket notation would build
|
|
17
|
+
* a real nested object and change which validators run against it.
|
|
18
|
+
* - Scalars are sent as `String(value)`, because multipart has no types.
|
|
19
|
+
* - `null` and `undefined` are dropped; multipart cannot represent them.
|
|
20
|
+
* - Empty arrays are rejected. `key[]=` decodes to `['']`, and for a numeric
|
|
21
|
+
* ID list that becomes `[0]` — the API's "every monitor" sentinel, which is
|
|
22
|
+
* the opposite of clearing the list.
|
|
23
|
+
*/
|
|
24
|
+
import { isPlainRecord } from './objects.js';
|
|
25
|
+
import { RequestInputError } from './request-input-error.js';
|
|
26
|
+
export function encodeFormBody(body, files = []) {
|
|
27
|
+
const form = new FormData();
|
|
28
|
+
appendValue(form, '', body);
|
|
29
|
+
for (const file of files)
|
|
30
|
+
form.append(file.field, file.content, file.filename);
|
|
31
|
+
return form;
|
|
32
|
+
}
|
|
33
|
+
function appendValue(form, path, value) {
|
|
34
|
+
if (value === undefined || value === null)
|
|
35
|
+
return;
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
if (value.length === 0) {
|
|
38
|
+
throw new RequestInputError(path, 'a non-empty array', `${path} cannot be cleared in a request that uploads a file. ` +
|
|
39
|
+
`Re-run without --file to send an empty ${path}, or split this into two commands.`);
|
|
40
|
+
}
|
|
41
|
+
for (const item of value)
|
|
42
|
+
form.append(`${path}[]`, stringifyFormValue(item));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (isPlainRecord(value)) {
|
|
46
|
+
for (const [key, child] of Object.entries(value)) {
|
|
47
|
+
appendValue(form, path === '' ? key : `${path}.${key}`, child);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
form.append(path, stringifyFormValue(value));
|
|
52
|
+
}
|
|
53
|
+
function stringifyFormValue(value) {
|
|
54
|
+
return typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/lib/invocation.js
CHANGED
|
@@ -19,12 +19,15 @@ export function agentEnvironmentDetected(env = process.env) {
|
|
|
19
19
|
export function detectInvocationMode(agentFlag, env = process.env) {
|
|
20
20
|
return agentFlag || agentEnvironmentDetected(env) ? 'agent' : 'human';
|
|
21
21
|
}
|
|
22
|
-
export
|
|
23
|
-
|
|
22
|
+
export function confirmationAvailable(mode) {
|
|
23
|
+
return mode === 'human' && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
24
|
+
}
|
|
25
|
+
export async function requestConfirmation(summary, mode) {
|
|
26
|
+
if (!confirmationAvailable(mode))
|
|
24
27
|
return false;
|
|
25
28
|
const reader = createInterface({ input: process.stdin, output: process.stderr });
|
|
26
29
|
try {
|
|
27
|
-
const answer = await reader.question(`${
|
|
30
|
+
const answer = await reader.question(`${summary}. Continue? [y/N] `);
|
|
28
31
|
return /^(y|yes)$/i.test(answer.trim());
|
|
29
32
|
}
|
|
30
33
|
finally {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { createApiClient } from '../api/client.js';
|
|
3
3
|
import { exitCodeFor, normalizeApiError } from '../api/errors.js';
|
|
4
|
+
import { bulkFailure, bulkFailureError, bulkFailureExitCode } from '../output/bulk-failure.js';
|
|
4
5
|
import { columnsFor, parseColumnsFlag } from '../output/columns.js';
|
|
5
6
|
import { normalizeResult } from '../output/normalize-result.js';
|
|
6
7
|
import { paginationNotice } from '../output/pagination-notice.js';
|
|
@@ -12,11 +13,15 @@ import { InputValidationError, validateInput } from '../runtime/validate-input.j
|
|
|
12
13
|
import { AuthenticationError, resolveApiKey } from './auth.js';
|
|
13
14
|
import { BaseCommand } from './base-command.js';
|
|
14
15
|
import { flagName } from './flag-name.js';
|
|
16
|
+
import { curatedCommandDescription } from './help-curation.js';
|
|
17
|
+
import { withDerivedFields } from './response-curation.js';
|
|
15
18
|
import { detectInvocationMode, isCI, requestConfirmation } from './invocation.js';
|
|
16
19
|
import { enrichOperationParserError } from './operation-parser-error.js';
|
|
20
|
+
import { redactSecrets } from './redact-secrets.js';
|
|
17
21
|
import { curatedRequestBodyField, curatedRequestExampleBody } from './request-curation.js';
|
|
18
22
|
import { synthesizedRequestExample } from './request-example.js';
|
|
19
|
-
import { buildRequestBody, buildUrl
|
|
23
|
+
import { buildRequestBody, buildUrl } from './request.js';
|
|
24
|
+
import { RequestInputError } from './request-input-error.js';
|
|
20
25
|
import { redactRequestPreview } from './request-preview.js';
|
|
21
26
|
export function createOperationCommand(operation, options = {}) {
|
|
22
27
|
const pathParameters = operation.parameters.filter((parameter) => parameter.in === 'path');
|
|
@@ -31,13 +36,12 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
31
36
|
description: 'JSON request body, @file, or - for stdin',
|
|
32
37
|
helpValue: '<json|@file|->',
|
|
33
38
|
}),
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
: {}),
|
|
39
|
+
// Every operation with a request body compiles that body as JSON
|
|
40
|
+
// unless --file forces multipart, so --dry-run is always offered
|
|
41
|
+
// here; the guard in run() rejects the multipart case.
|
|
42
|
+
'dry-run': Flags.boolean({
|
|
43
|
+
description: 'Compile and validate the request as JSON without authentication or networking',
|
|
44
|
+
}),
|
|
41
45
|
...(operation.contentTypes.includes('multipart/form-data')
|
|
42
46
|
? {
|
|
43
47
|
file: Flags.string({
|
|
@@ -55,6 +59,7 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
55
59
|
}
|
|
56
60
|
: {};
|
|
57
61
|
const defaultColumns = columnsFor(operation.commandId);
|
|
62
|
+
const description = curatedCommandDescription(operation);
|
|
58
63
|
const bodyFieldFlags = Object.fromEntries((operation.requestBodyFields ?? []).map((field) => [
|
|
59
64
|
field.flag,
|
|
60
65
|
flagForBodyField(curatedRequestBodyField(operation, field)),
|
|
@@ -90,6 +95,9 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
90
95
|
raw: Flags.boolean({
|
|
91
96
|
description: 'Emit the untouched API response as JSON; implies --format json',
|
|
92
97
|
}),
|
|
98
|
+
'reveal-secrets': Flags.boolean({
|
|
99
|
+
description: 'Show credential-like response fields instead of redacting them',
|
|
100
|
+
}),
|
|
93
101
|
...requestBodyFlags,
|
|
94
102
|
...bodyFieldFlags,
|
|
95
103
|
...Object.fromEntries(optionParameters.map((parameter) => [flagName(parameter.name), flagForParameter(parameter)])),
|
|
@@ -97,10 +105,10 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
97
105
|
class OperationCommand extends BaseCommand {
|
|
98
106
|
static args = commandArgs;
|
|
99
107
|
static description = options.canonicalCommand
|
|
100
|
-
? `${
|
|
108
|
+
? `${description}
|
|
101
109
|
|
|
102
110
|
Canonical command: ${options.canonicalCommand}`
|
|
103
|
-
:
|
|
111
|
+
: description;
|
|
104
112
|
static examples = examplesFor(operation);
|
|
105
113
|
static flags = commandFlags;
|
|
106
114
|
static summary = operation.summary;
|
|
@@ -201,7 +209,7 @@ Canonical command: ${options.canonicalCommand}`
|
|
|
201
209
|
return;
|
|
202
210
|
}
|
|
203
211
|
if (operation.destructive && flags.confirm !== true) {
|
|
204
|
-
const confirmed = await requestConfirmation(operation, mode);
|
|
212
|
+
const confirmed = await requestConfirmation(operation.summary, mode);
|
|
205
213
|
if (!confirmed) {
|
|
206
214
|
const error = {
|
|
207
215
|
code: 'CONFIRMATION_REQUIRED',
|
|
@@ -249,20 +257,41 @@ Canonical command: ${options.canonicalCommand}`
|
|
|
249
257
|
const apiError = normalizeApiError(response.status, payload);
|
|
250
258
|
return this.fail(apiError, exitCodeFor(response.status));
|
|
251
259
|
}
|
|
252
|
-
|
|
253
|
-
const
|
|
260
|
+
// --raw is the untouched API response, so derived fields stay out of it.
|
|
261
|
+
const normalized = flags.raw === true ? payload : withDerivedFields(normalizeResult(payload));
|
|
262
|
+
// Redacted before rendering, so column selection, truncation, and JSONL
|
|
263
|
+
// splitting all operate on the redacted values. --raw stays untouched.
|
|
264
|
+
const redaction = flags.raw === true || flags['reveal-secrets'] === true
|
|
265
|
+
? undefined
|
|
266
|
+
: redactSecrets(normalized);
|
|
267
|
+
const presented = redaction === undefined ? normalized : redaction.value;
|
|
268
|
+
const output = formatOutput(outputFormat, presented, {
|
|
254
269
|
allColumns: flags.all === true,
|
|
255
270
|
columns: requestedColumns ?? (flags.all === true ? undefined : defaultColumns),
|
|
256
271
|
});
|
|
257
272
|
if (output !== undefined)
|
|
258
273
|
this.log(output);
|
|
259
|
-
|
|
274
|
+
// Reported after the results are on stdout, so callers keep the per-item detail.
|
|
275
|
+
const failure = bulkFailure(payload);
|
|
276
|
+
if (failure)
|
|
277
|
+
return this.fail(bulkFailureError(failure), bulkFailureExitCode(failure));
|
|
278
|
+
const notice = paginationNotice(outputFormat, presented);
|
|
260
279
|
if (notice)
|
|
261
280
|
this.logToStderr(notice.message);
|
|
281
|
+
if (redaction !== undefined && redaction.redacted.length > 0) {
|
|
282
|
+
this.logToStderr(redactionNotice(redaction.redacted));
|
|
283
|
+
}
|
|
262
284
|
}
|
|
263
285
|
}
|
|
264
286
|
return OperationCommand;
|
|
265
287
|
}
|
|
288
|
+
const NOTICE_PATH_LIMIT = 3;
|
|
289
|
+
function redactionNotice(paths) {
|
|
290
|
+
const shown = paths.slice(0, NOTICE_PATH_LIMIT);
|
|
291
|
+
const remainder = paths.length - shown.length;
|
|
292
|
+
const list = remainder > 0 ? `${shown.join(', ')}, and ${remainder} more` : shown.join(', ');
|
|
293
|
+
return `Redacted credential-like response fields: ${list}. Re-run with --reveal-secrets to show them.`;
|
|
294
|
+
}
|
|
266
295
|
function flagForBodyField(field) {
|
|
267
296
|
const options = {
|
|
268
297
|
...(field.aliases ? { aliases: [...field.aliases] } : {}),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isRecord } from './objects.js';
|
|
2
|
+
import { isSensitiveFieldName } from './sensitive-fields.js';
|
|
3
|
+
const REDACTED = '[REDACTED]';
|
|
4
|
+
/**
|
|
5
|
+
* Replaces credential-like fields anywhere in a value, recursing through both
|
|
6
|
+
* objects and arrays. Deliberately shape-agnostic: the same pass covers dry-run
|
|
7
|
+
* request bodies and normalized API responses, whose resources nest under
|
|
8
|
+
* `items[]`.
|
|
9
|
+
*/
|
|
10
|
+
export function redactSecrets(value) {
|
|
11
|
+
const redacted = [];
|
|
12
|
+
return { value: redactValue(value, [], redacted), redacted };
|
|
13
|
+
}
|
|
14
|
+
function redactValue(value, path, redacted) {
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return value.map((item, index) => redactValue(item, [...path, String(index)], redacted));
|
|
17
|
+
}
|
|
18
|
+
if (!isRecord(value))
|
|
19
|
+
return value;
|
|
20
|
+
const contextual = contextualSensitiveKeys(value);
|
|
21
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
|
22
|
+
const childPath = [...path, key];
|
|
23
|
+
if (isSensitiveFieldName(key) || contextual.has(key)) {
|
|
24
|
+
redacted.push(formatPath(childPath));
|
|
25
|
+
return [key, REDACTED];
|
|
26
|
+
}
|
|
27
|
+
return [key, redactValue(child, childPath, redacted)];
|
|
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
|
+
}
|
|
44
|
+
function formatPath(segments) {
|
|
45
|
+
return segments.reduce((path, segment) => /^\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment, '');
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised while compiling a request body, before anything is sent. Carries the
|
|
3
|
+
* offending field path so the CLI can report it as a machine-readable error.
|
|
4
|
+
*/
|
|
5
|
+
export class RequestInputError extends Error {
|
|
6
|
+
path;
|
|
7
|
+
expected;
|
|
8
|
+
code = 'INVALID_INPUT';
|
|
9
|
+
exitCode = 2;
|
|
10
|
+
constructor(path, expected, message = `Invalid ${path}: expected ${expected}.`) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.expected = expected;
|
|
14
|
+
this.name = 'RequestInputError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -1,25 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const REDACTED = '[REDACTED]';
|
|
1
|
+
import { redactSecrets } from './redact-secrets.js';
|
|
2
|
+
/** Dry-run view of a compiled request body, with credential fields replaced. */
|
|
4
3
|
export function redactRequestPreview(body) {
|
|
5
|
-
const redacted =
|
|
6
|
-
return { body:
|
|
7
|
-
}
|
|
8
|
-
function redactValue(value, path, redacted) {
|
|
9
|
-
if (Array.isArray(value)) {
|
|
10
|
-
return value.map((item, index) => redactValue(item, [...path, String(index)], redacted));
|
|
11
|
-
}
|
|
12
|
-
if (!isRecord(value))
|
|
13
|
-
return value;
|
|
14
|
-
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
|
15
|
-
const childPath = [...path, key];
|
|
16
|
-
if (isSensitiveFieldName(key)) {
|
|
17
|
-
redacted.push(formatPath(childPath));
|
|
18
|
-
return [key, REDACTED];
|
|
19
|
-
}
|
|
20
|
-
return [key, redactValue(child, childPath, redacted)];
|
|
21
|
-
}));
|
|
22
|
-
}
|
|
23
|
-
function formatPath(segments) {
|
|
24
|
-
return segments.reduce((path, segment) => /^\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment, '');
|
|
4
|
+
const { redacted, value } = redactSecrets(body);
|
|
5
|
+
return { body: value, redacted };
|
|
25
6
|
}
|
package/dist/lib/request.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
|
+
import { encodeFormBody } from './form-body.js';
|
|
3
4
|
import { assignPath, isPlainRecord, valueAtPath } from './objects.js';
|
|
4
5
|
import { cliRequestDefaults, curatedRequestBodyFields, requestCurationIssue, } from './request-curation.js';
|
|
5
|
-
|
|
6
|
-
path;
|
|
7
|
-
expected;
|
|
8
|
-
code = 'INVALID_INPUT';
|
|
9
|
-
exitCode = 2;
|
|
10
|
-
constructor(path, expected, message = `Invalid ${path}: expected ${expected}.`) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.path = path;
|
|
13
|
-
this.expected = expected;
|
|
14
|
-
this.name = 'RequestInputError';
|
|
15
|
-
}
|
|
16
|
-
}
|
|
6
|
+
import { RequestInputError } from './request-input-error.js';
|
|
17
7
|
export function buildUrl(operation, input, apiUrl) {
|
|
18
8
|
let path = operation.path;
|
|
19
9
|
for (const parameter of operation.parameters.filter((candidate) => candidate.in === 'path')) {
|
|
@@ -54,13 +44,17 @@ export async function buildRequestBody(operation, flags, headers) {
|
|
|
54
44
|
return undefined;
|
|
55
45
|
}
|
|
56
46
|
const objectBody = toObject(bodyValue);
|
|
47
|
+
const typedAssignments = new Map();
|
|
57
48
|
for (const field of bodyFields) {
|
|
58
49
|
const value = flags[field.flag];
|
|
59
|
-
if (value
|
|
60
|
-
|
|
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 });
|
|
61
55
|
}
|
|
62
56
|
for (const assignment of assignments)
|
|
63
|
-
assignValue(objectBody, assignment);
|
|
57
|
+
assignValue(objectBody, assignment, typedAssignments);
|
|
64
58
|
for (const [path, value] of Object.entries(operation.requestBodyDefaults ?? {})) {
|
|
65
59
|
const supplied = valueAtPath(objectBody, path);
|
|
66
60
|
if (supplied !== undefined && supplied !== value) {
|
|
@@ -70,19 +64,21 @@ export async function buildRequestBody(operation, flags, headers) {
|
|
|
70
64
|
}
|
|
71
65
|
applyRequestCuration(operation, objectBody);
|
|
72
66
|
validateBodyFields(bodyFields, objectBody);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const bytes = await readFile(path);
|
|
79
|
-
form.append(field, new Blob([bytes]), basename(path));
|
|
80
|
-
}
|
|
81
|
-
return form;
|
|
82
|
-
}
|
|
67
|
+
// Multipart exists only to carry file uploads. The operations that document
|
|
68
|
+
// it accept JSON too, and JSON is the only encoding that keeps numeric types,
|
|
69
|
+
// nested objects, and empty arrays intact — so it is the default.
|
|
70
|
+
if (files.length > 0)
|
|
71
|
+
return encodeFormBody(objectBody, await readFileParts(files));
|
|
83
72
|
headers.set('content-type', 'application/json');
|
|
84
73
|
return JSON.stringify(objectBody);
|
|
85
74
|
}
|
|
75
|
+
async function readFileParts(files) {
|
|
76
|
+
return Promise.all(files.map(async (file) => {
|
|
77
|
+
const [field, path] = splitAssignment(file, '--file');
|
|
78
|
+
const bytes = await readFile(path);
|
|
79
|
+
return { content: new Blob([bytes]), field, filename: basename(path) };
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
86
82
|
function applyRequestCuration(operation, body) {
|
|
87
83
|
for (const [path, value] of Object.entries(cliRequestDefaults(operation))) {
|
|
88
84
|
if (valueAtPath(body, path) === undefined)
|
|
@@ -92,17 +88,6 @@ function applyRequestCuration(operation, body) {
|
|
|
92
88
|
if (issue)
|
|
93
89
|
throw new RequestInputError(issue.path, issue.expected);
|
|
94
90
|
}
|
|
95
|
-
function appendFormValues(form, values) {
|
|
96
|
-
for (const [key, value] of Object.entries(values)) {
|
|
97
|
-
if (Array.isArray(value))
|
|
98
|
-
value.forEach((item) => form.append(key, stringifyFormValue(item)));
|
|
99
|
-
else if (value !== undefined && value !== null)
|
|
100
|
-
form.append(key, stringifyFormValue(value));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
function stringifyFormValue(value) {
|
|
104
|
-
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
105
|
-
}
|
|
106
91
|
async function readBodyFlag(value) {
|
|
107
92
|
const source = value === '-'
|
|
108
93
|
? await readStandardInput()
|
|
@@ -130,9 +115,41 @@ function toObject(value) {
|
|
|
130
115
|
}
|
|
131
116
|
return structuredClone(value);
|
|
132
117
|
}
|
|
133
|
-
function assignValue(target, assignment) {
|
|
118
|
+
function assignValue(target, assignment, typedAssignments) {
|
|
134
119
|
const [path, rawValue] = splitAssignment(assignment, '--set');
|
|
135
|
-
|
|
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);
|
|
136
153
|
}
|
|
137
154
|
function splitAssignment(value, flag) {
|
|
138
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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:os';
|
|
3
|
+
const SKILLS_INSTALL_ARGS = ['skills', 'add', 'uptimerobot/ai'];
|
|
4
|
+
export const SKILLS_INSTALL_COMMAND = ['npx', ...SKILLS_INSTALL_ARGS].join(' ');
|
|
5
|
+
export async function runSkillsInstaller() {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const windows = process.platform === 'win32';
|
|
8
|
+
const executable = windows ? (process.env.ComSpec ?? 'cmd.exe') : 'npx';
|
|
9
|
+
const args = windows
|
|
10
|
+
? ['/d', '/s', '/c', 'npx.cmd', ...SKILLS_INSTALL_ARGS]
|
|
11
|
+
: SKILLS_INSTALL_ARGS;
|
|
12
|
+
const child = spawn(executable, args, { stdio: 'inherit' });
|
|
13
|
+
child.once('error', reject);
|
|
14
|
+
child.once('close', (exitCode, signal) => {
|
|
15
|
+
const signalNumber = signal === null ? undefined : constants.signals[signal];
|
|
16
|
+
resolve(exitCode ?? (signalNumber === undefined ? 1 : 128 + signalNumber));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isPlainRecord } from '../lib/objects.js';
|
|
2
|
+
/**
|
|
3
|
+
* Detects a wholly or partially failed bulk operation. Bulk endpoints answer
|
|
4
|
+
* HTTP 201 even when every item failed, so the response body is the only
|
|
5
|
+
* signal that the command did not do what was asked. Returns undefined when
|
|
6
|
+
* the payload is not a bulk response or when nothing failed, so any returned
|
|
7
|
+
* value is a failure the caller must report.
|
|
8
|
+
*/
|
|
9
|
+
export function bulkFailure(payload) {
|
|
10
|
+
if (!isPlainRecord(payload))
|
|
11
|
+
return undefined;
|
|
12
|
+
const results = payload.results;
|
|
13
|
+
if (!Array.isArray(results))
|
|
14
|
+
return undefined;
|
|
15
|
+
const items = results.filter((item) => isPlainRecord(item) && item.status === 'error');
|
|
16
|
+
// totalError is optional in practice, so per-item statuses stay authoritative.
|
|
17
|
+
const reported = typeof payload.totalError === 'number' ? payload.totalError : 0;
|
|
18
|
+
const failed = Math.max(reported, items.length);
|
|
19
|
+
if (failed === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
return {
|
|
22
|
+
failed,
|
|
23
|
+
items,
|
|
24
|
+
succeeded: typeof payload.totalSuccess === 'number'
|
|
25
|
+
? payload.totalSuccess
|
|
26
|
+
: results.length - items.length,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function bulkFailureError(failure) {
|
|
30
|
+
const counts = `${failure.failed} failed, ${failure.succeeded} succeeded`;
|
|
31
|
+
const total = failure.succeeded === 0;
|
|
32
|
+
return {
|
|
33
|
+
code: total ? 'BULK_FAILED' : 'BULK_PARTIAL_FAILURE',
|
|
34
|
+
details: failure.items,
|
|
35
|
+
message: total
|
|
36
|
+
? `The bulk operation failed for every item (${counts}).`
|
|
37
|
+
: `The bulk operation partially failed (${counts}).`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function bulkFailureExitCode(failure) {
|
|
41
|
+
return failure.succeeded === 0 ? 1 : 3;
|
|
42
|
+
}
|
package/openapi/openapi.yaml
CHANGED
|
@@ -2300,6 +2300,8 @@ components:
|
|
|
2300
2300
|
exclusiveMinimum: false
|
|
2301
2301
|
comment:
|
|
2302
2302
|
type: string
|
|
2303
|
+
publishOnStatusPage:
|
|
2304
|
+
type: boolean
|
|
2303
2305
|
created:
|
|
2304
2306
|
oneOf:
|
|
2305
2307
|
- {}
|
|
@@ -2317,6 +2319,7 @@ components:
|
|
|
2317
2319
|
required:
|
|
2318
2320
|
- id
|
|
2319
2321
|
- comment
|
|
2322
|
+
- publishOnStatusPage
|
|
2320
2323
|
- created
|
|
2321
2324
|
- user
|
|
2322
2325
|
required:
|
|
@@ -2508,6 +2511,11 @@ components:
|
|
|
2508
2511
|
maxLength: 10000
|
|
2509
2512
|
description: Updated content of the comment
|
|
2510
2513
|
example: Issue has been identified and fix is in progress...
|
|
2514
|
+
publishOnStatusPage:
|
|
2515
|
+
type: boolean
|
|
2516
|
+
description: Whether the comment is published on the status page
|
|
2517
|
+
example: true
|
|
2518
|
+
default: false
|
|
2511
2519
|
required:
|
|
2512
2520
|
- content
|
|
2513
2521
|
IncidentCommentDto:
|
|
@@ -2519,6 +2527,8 @@ components:
|
|
|
2519
2527
|
exclusiveMinimum: false
|
|
2520
2528
|
comment:
|
|
2521
2529
|
type: string
|
|
2530
|
+
publishOnStatusPage:
|
|
2531
|
+
type: boolean
|
|
2522
2532
|
created:
|
|
2523
2533
|
oneOf:
|
|
2524
2534
|
- {}
|
|
@@ -2536,6 +2546,7 @@ components:
|
|
|
2536
2546
|
required:
|
|
2537
2547
|
- id
|
|
2538
2548
|
- comment
|
|
2549
|
+
- publishOnStatusPage
|
|
2539
2550
|
- created
|
|
2540
2551
|
- user
|
|
2541
2552
|
CreateIncidentCommentRequestDto:
|
|
@@ -2546,6 +2557,11 @@ components:
|
|
|
2546
2557
|
maxLength: 10000
|
|
2547
2558
|
description: Content of the comment
|
|
2548
2559
|
example: Investigating the issue...
|
|
2560
|
+
publishOnStatusPage:
|
|
2561
|
+
type: boolean
|
|
2562
|
+
description: Whether the comment is published on the status page
|
|
2563
|
+
example: true
|
|
2564
|
+
default: false
|
|
2549
2565
|
required:
|
|
2550
2566
|
- content
|
|
2551
2567
|
CreateMonitorGroupDto:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uptimerobot/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Official UptimeRobot CLI — manage uptime monitors, incidents, status pages, and alert contacts from your terminal, scripts, or CI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"uptimerobot",
|
|
@@ -109,13 +109,24 @@
|
|
|
109
109
|
"description": "Manage monitors and statistics",
|
|
110
110
|
"subtopics": {
|
|
111
111
|
"bulk": {
|
|
112
|
-
"description": "Run bulk monitor actions"
|
|
112
|
+
"description": "Run bulk monitor actions\n\nPause, start, and update report acceptance, not completion: the API answers success as soon as the write is accepted and carries no operation id. Confirm with `uptimerobot monitors get <id>` before depending on the new state."
|
|
113
|
+
},
|
|
114
|
+
"create": {
|
|
115
|
+
"description": "Create a monitor of a given type"
|
|
113
116
|
},
|
|
114
117
|
"stats": {
|
|
115
|
-
"description": "Query monitor statistics"
|
|
118
|
+
"description": "Query monitor statistics",
|
|
119
|
+
"subtopics": {
|
|
120
|
+
"response-time": {
|
|
121
|
+
"description": "Query response time statistics overall or by region"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
116
124
|
}
|
|
117
125
|
}
|
|
118
126
|
},
|
|
127
|
+
"skills": {
|
|
128
|
+
"description": "Install UptimeRobot AI skills"
|
|
129
|
+
},
|
|
119
130
|
"status-pages": {
|
|
120
131
|
"description": "Manage public status pages",
|
|
121
132
|
"subtopics": {
|