@uptimerobot/cli 0.2.0 → 0.3.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 +38 -8
- package/bin/run.js +8 -8
- package/dist/commands/help.js +15 -3
- package/dist/commands/skills/install.js +38 -0
- package/dist/lib/base-command.js +7 -0
- package/dist/lib/command-id.js +50 -0
- package/dist/lib/command-suggestions.js +1 -0
- package/dist/lib/form-body.js +55 -0
- package/dist/lib/invocation.js +6 -3
- package/dist/lib/operation-command.js +36 -11
- package/dist/lib/redact-secrets.js +31 -0
- package/dist/lib/request-input-error.js +16 -0
- package/dist/lib/request-preview.js +4 -23
- package/dist/lib/request.js +14 -33
- package/dist/lib/skills-installer.js +19 -0
- package/dist/output/bulk-failure.js +42 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -36,6 +36,16 @@ uptimerobot --version
|
|
|
36
36
|
uptimerobot --help
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
## AI agent skills
|
|
40
|
+
|
|
41
|
+
Install the [UptimeRobot AI skills](https://github.com/uptimerobot/ai) for supported coding agents:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
uptimerobot skills install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
39
49
|
## Authentication
|
|
40
50
|
|
|
41
51
|
[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 +111,8 @@ uptimerobot monitors create keyword --help
|
|
|
101
111
|
uptimerobot incidents comments create --help
|
|
102
112
|
```
|
|
103
113
|
|
|
114
|
+
`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.
|
|
115
|
+
|
|
104
116
|
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
117
|
|
|
106
118
|
Typed flags cover normal use. Full API request bodies remain available when needed:
|
|
@@ -132,19 +144,37 @@ Dry-run output contains the final method, path, content type, and body. Recogniz
|
|
|
132
144
|
|
|
133
145
|
Interactive terminals use tables by default. Piped, redirected, and agent-driven commands use normalized JSON by default.
|
|
134
146
|
|
|
135
|
-
| Option
|
|
136
|
-
|
|
|
137
|
-
| `--json`
|
|
138
|
-
| `--format jsonl`
|
|
139
|
-
| `--format table`
|
|
140
|
-
| `--format plain`
|
|
141
|
-
| `--raw`
|
|
147
|
+
| Option | Output |
|
|
148
|
+
| ------------------ | ------------------------------------ |
|
|
149
|
+
| `--json` | Normalized JSON |
|
|
150
|
+
| `--format jsonl` | One normalized resource per line |
|
|
151
|
+
| `--format table` | Human-readable table |
|
|
152
|
+
| `--format plain` | Headerless, tab-separated rows |
|
|
153
|
+
| `--raw` | Untouched API response as JSON |
|
|
154
|
+
| `--reveal-secrets` | Credential-like fields shown in full |
|
|
155
|
+
|
|
156
|
+
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
157
|
|
|
143
158
|
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
159
|
|
|
145
160
|
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
161
|
|
|
147
|
-
Machine-readable failures are emitted as JSON on stderr
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
The exit code identifies the failure:
|
|
165
|
+
|
|
166
|
+
| Exit code | Meaning |
|
|
167
|
+
| --------- | ----------------------------------------------------------------------------------- |
|
|
168
|
+
| `0` | Success |
|
|
169
|
+
| `1` | Generic failure, including a bulk operation where every item failed (`BULK_FAILED`) |
|
|
170
|
+
| `2` | Invalid input |
|
|
171
|
+
| `3` | Bulk operation where some items failed (`BULK_PARTIAL_FAILURE`) |
|
|
172
|
+
| `4` | Unauthenticated (HTTP 401) |
|
|
173
|
+
| `5` | Forbidden (HTTP 403) |
|
|
174
|
+
| `6` | Not found (HTTP 404) |
|
|
175
|
+
| `7` | Rate limited (HTTP 429) |
|
|
176
|
+
|
|
177
|
+
`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`.
|
|
148
178
|
|
|
149
179
|
## Safety
|
|
150
180
|
|
package/bin/run.js
CHANGED
|
@@ -15,6 +15,8 @@ if (!nodeVersionSupported) {
|
|
|
15
15
|
|
|
16
16
|
const { flush, handle, run } = await import('@oclif/core');
|
|
17
17
|
const { commandSuggestion } = await import('../dist/lib/command-suggestions.js');
|
|
18
|
+
const { isUnknownCommandMessage, withConfiguredCommandId } =
|
|
19
|
+
await import('../dist/lib/command-id.js');
|
|
18
20
|
const { errorEnvelope, exitCodeForError, machineOutputRequested, wasErrorHandled } =
|
|
19
21
|
await import('../dist/lib/cli-errors.js');
|
|
20
22
|
|
|
@@ -23,14 +25,12 @@ try {
|
|
|
23
25
|
await flush();
|
|
24
26
|
} catch (error) {
|
|
25
27
|
await flush();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
) {
|
|
33
|
-
error.suggestions = [`uptimerobot ${suggestion}`];
|
|
28
|
+
if (typeof error === 'object' && error !== null && isUnknownCommandMessage(error.message ?? '')) {
|
|
29
|
+
// oclif names the command with its colon-joined internal id; report it the
|
|
30
|
+
// way the CLI accepts it, so the message is a syntax the user can retype.
|
|
31
|
+
error.message = withConfiguredCommandId(error.message);
|
|
32
|
+
const suggestion = commandSuggestion(process.argv.slice(2));
|
|
33
|
+
if (suggestion) error.suggestions = [`uptimerobot ${suggestion}`];
|
|
34
34
|
}
|
|
35
35
|
if (wasErrorHandled(error)) {
|
|
36
36
|
process.exitCode = exitCodeForError(error);
|
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
|
+
}
|
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
|
+
}
|
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';
|
|
@@ -14,9 +15,11 @@ import { BaseCommand } from './base-command.js';
|
|
|
14
15
|
import { flagName } from './flag-name.js';
|
|
15
16
|
import { detectInvocationMode, isCI, requestConfirmation } from './invocation.js';
|
|
16
17
|
import { enrichOperationParserError } from './operation-parser-error.js';
|
|
18
|
+
import { redactSecrets } from './redact-secrets.js';
|
|
17
19
|
import { curatedRequestBodyField, curatedRequestExampleBody } from './request-curation.js';
|
|
18
20
|
import { synthesizedRequestExample } from './request-example.js';
|
|
19
|
-
import { buildRequestBody, buildUrl
|
|
21
|
+
import { buildRequestBody, buildUrl } from './request.js';
|
|
22
|
+
import { RequestInputError } from './request-input-error.js';
|
|
20
23
|
import { redactRequestPreview } from './request-preview.js';
|
|
21
24
|
export function createOperationCommand(operation, options = {}) {
|
|
22
25
|
const pathParameters = operation.parameters.filter((parameter) => parameter.in === 'path');
|
|
@@ -31,13 +34,12 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
31
34
|
description: 'JSON request body, @file, or - for stdin',
|
|
32
35
|
helpValue: '<json|@file|->',
|
|
33
36
|
}),
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
: {}),
|
|
37
|
+
// Every operation with a request body compiles that body as JSON
|
|
38
|
+
// unless --file forces multipart, so --dry-run is always offered
|
|
39
|
+
// here; the guard in run() rejects the multipart case.
|
|
40
|
+
'dry-run': Flags.boolean({
|
|
41
|
+
description: 'Compile and validate the request as JSON without authentication or networking',
|
|
42
|
+
}),
|
|
41
43
|
...(operation.contentTypes.includes('multipart/form-data')
|
|
42
44
|
? {
|
|
43
45
|
file: Flags.string({
|
|
@@ -90,6 +92,9 @@ export function createOperationCommand(operation, options = {}) {
|
|
|
90
92
|
raw: Flags.boolean({
|
|
91
93
|
description: 'Emit the untouched API response as JSON; implies --format json',
|
|
92
94
|
}),
|
|
95
|
+
'reveal-secrets': Flags.boolean({
|
|
96
|
+
description: 'Show credential-like response fields instead of redacting them',
|
|
97
|
+
}),
|
|
93
98
|
...requestBodyFlags,
|
|
94
99
|
...bodyFieldFlags,
|
|
95
100
|
...Object.fromEntries(optionParameters.map((parameter) => [flagName(parameter.name), flagForParameter(parameter)])),
|
|
@@ -201,7 +206,7 @@ Canonical command: ${options.canonicalCommand}`
|
|
|
201
206
|
return;
|
|
202
207
|
}
|
|
203
208
|
if (operation.destructive && flags.confirm !== true) {
|
|
204
|
-
const confirmed = await requestConfirmation(operation, mode);
|
|
209
|
+
const confirmed = await requestConfirmation(operation.summary, mode);
|
|
205
210
|
if (!confirmed) {
|
|
206
211
|
const error = {
|
|
207
212
|
code: 'CONFIRMATION_REQUIRED',
|
|
@@ -250,19 +255,39 @@ Canonical command: ${options.canonicalCommand}`
|
|
|
250
255
|
return this.fail(apiError, exitCodeFor(response.status));
|
|
251
256
|
}
|
|
252
257
|
const normalized = flags.raw === true ? payload : normalizeResult(payload);
|
|
253
|
-
|
|
258
|
+
// Redacted before rendering, so column selection, truncation, and JSONL
|
|
259
|
+
// splitting all operate on the redacted values. --raw stays untouched.
|
|
260
|
+
const redaction = flags.raw === true || flags['reveal-secrets'] === true
|
|
261
|
+
? undefined
|
|
262
|
+
: redactSecrets(normalized);
|
|
263
|
+
const presented = redaction === undefined ? normalized : redaction.value;
|
|
264
|
+
const output = formatOutput(outputFormat, presented, {
|
|
254
265
|
allColumns: flags.all === true,
|
|
255
266
|
columns: requestedColumns ?? (flags.all === true ? undefined : defaultColumns),
|
|
256
267
|
});
|
|
257
268
|
if (output !== undefined)
|
|
258
269
|
this.log(output);
|
|
259
|
-
|
|
270
|
+
// Reported after the results are on stdout, so callers keep the per-item detail.
|
|
271
|
+
const failure = bulkFailure(payload);
|
|
272
|
+
if (failure)
|
|
273
|
+
return this.fail(bulkFailureError(failure), bulkFailureExitCode(failure));
|
|
274
|
+
const notice = paginationNotice(outputFormat, presented);
|
|
260
275
|
if (notice)
|
|
261
276
|
this.logToStderr(notice.message);
|
|
277
|
+
if (redaction !== undefined && redaction.redacted.length > 0) {
|
|
278
|
+
this.logToStderr(redactionNotice(redaction.redacted));
|
|
279
|
+
}
|
|
262
280
|
}
|
|
263
281
|
}
|
|
264
282
|
return OperationCommand;
|
|
265
283
|
}
|
|
284
|
+
const NOTICE_PATH_LIMIT = 3;
|
|
285
|
+
function redactionNotice(paths) {
|
|
286
|
+
const shown = paths.slice(0, NOTICE_PATH_LIMIT);
|
|
287
|
+
const remainder = paths.length - shown.length;
|
|
288
|
+
const list = remainder > 0 ? `${shown.join(', ')}, and ${remainder} more` : shown.join(', ');
|
|
289
|
+
return `Redacted credential-like response fields: ${list}. Re-run with --reveal-secrets to show them.`;
|
|
290
|
+
}
|
|
266
291
|
function flagForBodyField(field) {
|
|
267
292
|
const options = {
|
|
268
293
|
...(field.aliases ? { aliases: [...field.aliases] } : {}),
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => {
|
|
21
|
+
const childPath = [...path, key];
|
|
22
|
+
if (isSensitiveFieldName(key)) {
|
|
23
|
+
redacted.push(formatPath(childPath));
|
|
24
|
+
return [key, REDACTED];
|
|
25
|
+
}
|
|
26
|
+
return [key, redactValue(child, childPath, redacted)];
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
function formatPath(segments) {
|
|
30
|
+
return segments.reduce((path, segment) => /^\d+$/.test(segment) ? `${path}[${segment}]` : path ? `${path}.${segment}` : segment, '');
|
|
31
|
+
}
|
|
@@ -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')) {
|
|
@@ -70,19 +60,21 @@ export async function buildRequestBody(operation, flags, headers) {
|
|
|
70
60
|
}
|
|
71
61
|
applyRequestCuration(operation, objectBody);
|
|
72
62
|
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
|
-
}
|
|
63
|
+
// Multipart exists only to carry file uploads. The operations that document
|
|
64
|
+
// it accept JSON too, and JSON is the only encoding that keeps numeric types,
|
|
65
|
+
// nested objects, and empty arrays intact — so it is the default.
|
|
66
|
+
if (files.length > 0)
|
|
67
|
+
return encodeFormBody(objectBody, await readFileParts(files));
|
|
83
68
|
headers.set('content-type', 'application/json');
|
|
84
69
|
return JSON.stringify(objectBody);
|
|
85
70
|
}
|
|
71
|
+
async function readFileParts(files) {
|
|
72
|
+
return Promise.all(files.map(async (file) => {
|
|
73
|
+
const [field, path] = splitAssignment(file, '--file');
|
|
74
|
+
const bytes = await readFile(path);
|
|
75
|
+
return { content: new Blob([bytes]), field, filename: basename(path) };
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
86
78
|
function applyRequestCuration(operation, body) {
|
|
87
79
|
for (const [path, value] of Object.entries(cliRequestDefaults(operation))) {
|
|
88
80
|
if (valueAtPath(body, path) === undefined)
|
|
@@ -92,17 +84,6 @@ function applyRequestCuration(operation, body) {
|
|
|
92
84
|
if (issue)
|
|
93
85
|
throw new RequestInputError(issue.path, issue.expected);
|
|
94
86
|
}
|
|
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
87
|
async function readBodyFlag(value) {
|
|
107
88
|
const source = value === '-'
|
|
108
89
|
? await readStandardInput()
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uptimerobot/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -111,11 +111,22 @@
|
|
|
111
111
|
"bulk": {
|
|
112
112
|
"description": "Run bulk monitor actions"
|
|
113
113
|
},
|
|
114
|
+
"create": {
|
|
115
|
+
"description": "Create a monitor of a given type"
|
|
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": {
|