@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 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 | Output |
136
- | ---------------- | -------------------------------- |
137
- | `--json` | Normalized JSON |
138
- | `--format jsonl` | One normalized resource per line |
139
- | `--format table` | Human-readable table |
140
- | `--format plain` | Headerless, tab-separated rows |
141
- | `--raw` | Untouched API response as JSON |
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 while stdout remains empty.
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
- const suggestion = commandSuggestion(process.argv.slice(2));
27
- if (
28
- suggestion &&
29
- typeof error === 'object' &&
30
- error !== null &&
31
- /^command .+ not found$/.test(error.message ?? '')
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);
@@ -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
- static description = 'Print the command overview, identical to running uptimerobot --help';
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
+ }
@@ -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
+ }
@@ -11,6 +11,7 @@ export const HANDWRITTEN_COMMANDS = [
11
11
  'auth whoami',
12
12
  'help',
13
13
  'monitors schema',
14
+ 'skills install',
14
15
  ];
15
16
  const commands = [
16
17
  ...Object.keys(operations).map((commandId) => commandId.replaceAll(':', ' ')),
@@ -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
+ }
@@ -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 async function requestConfirmation(operation, mode) {
23
- if (mode === 'agent' || !process.stdin.isTTY || !process.stdout.isTTY)
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(`${operation.summary}. Continue? [y/N] `);
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, RequestInputError } from './request.js';
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
- ...(operation.contentTypes.includes('application/json')
35
- ? {
36
- 'dry-run': Flags.boolean({
37
- description: 'Compile and validate the request as JSON without authentication or networking',
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
- const output = formatOutput(outputFormat, normalized, {
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
- const notice = paginationNotice(outputFormat, normalized);
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 { isRecord } from './objects.js';
2
- import { isSensitiveFieldName } from './sensitive-fields.js';
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: redactValue(body, [], redacted), redacted };
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
  }
@@ -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
- 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
- }
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
- if (operation.contentTypes.includes('multipart/form-data')) {
74
- const form = new FormData();
75
- appendFormValues(form, objectBody);
76
- for (const file of files) {
77
- const [field, path] = splitAssignment(file, '--file');
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.2.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": {