diff --git a/README.md b/README.md index 6991ab3..e0a9d7a 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,24 @@ cloudcannon orgs sites list --- +### `orgs inboxes create --name ` + +Create an inbox for an organization. If you belong to only one organization, it is selected automatically. The key defaults to a slug of the name, so `--name "Contact form"` becomes `contact-form`. + +```sh +cloudcannon orgs inboxes create --org my-org --name "Contact form" --key contact +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--org ` | The organization name, ID, or UUID | +| `--name ` | The inbox name (required) | +| `--key ` | The inbox key your forms post to, which defaults to a slug of the name | + +--- + ### `orgs inboxes list [org]` List all inboxes for an organization. If you belong to only one organization, it is selected automatically. @@ -740,6 +758,207 @@ cloudcannon builds print-logs --build --- +### `inboxes get --inbox ` + +Get an inbox, including the key your forms post to. + +```sh +cloudcannon inboxes get --inbox contact +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--inbox ` | The inbox name, ID, key, or UUID (required) | + +--- + +### `inboxes update --inbox ` + +Update an inbox's settings. Only the flags you pass are changed. Changing the key stops every form posting to the old one from reaching this inbox. + +```sh +cloudcannon inboxes update --inbox contact --name "Sales enquiries" +cloudcannon inboxes update --inbox contact --allow-uploads +cloudcannon inboxes update --inbox contact --captcha-type turnstile --captcha-key SITE_KEY --captcha-secret SECRET +cloudcannon inboxes update --inbox contact --captcha-type google_enterprise --captcha-key KEY_ID --captcha-secret API_KEY --captcha-project-id my-project +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--inbox ` | The inbox name, ID, key, or UUID (required) | +| `--name ` | A new name for the inbox | +| `--key ` | A new key for the inbox, which changes where your forms post to | +| `--allow-uploads`, `--no-allow-uploads` | Accept file uploads from forms posting to this inbox | +| `--keep-form-hook-days ` | The number of days to retain submissions | +| `--captcha-type ` | The captcha provider checking submissions. One of `google`, `google_enterprise`, `hcaptcha`, `turnstile` | +| `--captcha-key ` | The captcha provider's site key, or the reCAPTCHA key ID for `google_enterprise` | +| `--captcha-secret ` | The captcha provider's secret key, or a Google Cloud API key for `google_enterprise`. It is never shown again | +| `--captcha-project-id ` | The Google Cloud project ID holding the reCAPTCHA key, required for `google_enterprise` | +| `--captcha-min-score ` | Reject reCAPTCHA tokens scoring below this, from 0 to 1. Applies to `google` (v3 only) and `google_enterprise`, and is 0.5 unless set | +| `--captcha-send-sitekey`, `--no-captcha-send-sitekey` | Tell hCaptcha which site key to expect, so it rejects a token from a form using another of your site keys. On unless turned off | +| `--no-captcha` | Stop checking submissions to this inbox with a captcha, which clears the provider and its keys | + +--- + +### `inboxes delete --inbox ` + +Delete an inbox, along with its submissions and targets. Requires `--force`. + +```sh +cloudcannon inboxes delete --inbox contact --force +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--inbox ` | The inbox name, ID, key, or UUID (required) | +| `--force` | Confirm the deletion without being asked | + +--- + +### `inboxes connect --inbox --site ` + +Connect a site to an inbox so its forms can post submissions. + +```sh +cloudcannon inboxes connect --inbox contact --site example.com +cloudcannon inboxes connect --inbox contact --site example.com --no-require-captcha +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--inbox ` | The inbox name, ID, key, or UUID (required) | +| `--site ` | The site name, ID, UUID, or domain (required) | +| `--default` | Make this the site's default inbox | +| `--require-captcha`, `--no-require-captcha` | Reject submissions from this site without the inbox's captcha. Defaults to on when the inbox has a captcha provider, so add the provider's widget to your forms first | + +--- + +### `inboxes disconnect --inbox --site ` + +Disconnect a site from an inbox so its forms stop posting submissions to it. Requires `--force`. + +```sh +cloudcannon inboxes disconnect --inbox contact --site example.com --force +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--inbox ` | The inbox name, ID, key, or UUID (required) | +| `--site ` | The site name, ID, UUID, or domain (required) | +| `--force` | Confirm the disconnection without being asked | + +--- + +### `inboxes targets list --inbox ` + +List the targets an inbox forwards submissions to, each with its UUID. + +```sh +cloudcannon inboxes targets list --inbox contact +cloudcannon inboxes targets list --inbox contact --filter target_type=email +cloudcannon inboxes targets list --inbox contact --filter validated=false +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--inbox ` | The inbox name, ID, key, or UUID (required) | +| `--filter ` | Comma-separated key=value pairs to filter by, using a target's own fields such as `target_type` or `validated` | + +--- + +### `inboxes targets add --inbox --type ` + +Add a target that an inbox forwards submissions to. A `hubspot` target takes `--portal-id` and `--form-guid` in place of `--target`. + +An `email` target forwards nothing until someone opens the validation link CloudCannon emails to that address, and a webhook target that needs domain verification forwards nothing until its DNS TXT record is in place. When a new target is not yet validated, the command prints what is still outstanding on stderr, including the record name and value to add. + +```sh +cloudcannon inboxes targets add --inbox contact --type email --target sales@example.com +cloudcannon inboxes targets add --inbox contact --type slack --target https://hooks.slack.com/services/T0/B0/XXXX +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--inbox ` | The inbox name, ID, key, or UUID (required) | +| `--type ` | The integration this target sends to (required). One of `email`, `slack`, `zapier`, `make`, `ifttt`, `discord`, `teams`, `hubspot`, `n8n`, `pipedream`, `webhook` | +| `--target ` | The destination email address or webhook URL, not used by HubSpot targets | +| `--portal-id ` | The HubSpot portal ID, used instead of `--target` for HubSpot targets | +| `--form-guid ` | The HubSpot form GUID, used instead of `--target` for HubSpot targets | +| `--payload-format ` | Override the payload format sent to the target. One of `email`, `raw`, `slack`, `discord`, `teams`, `ifttt`, `hubspot`, `form_encoded` | +| `--block-spam` | Hold back submissions flagged by spam detection | +| `--block-spam-list` | Hold back submissions matching the CloudCannon spam blocklist | +| `--field-map ` | Comma-separated hubspot_field=form_field pairs, required when sending the HubSpot payload format | + +--- + +### `inboxes targets update --target-uuid ` + +Update a target's destination and options. A target's type cannot be changed, so remove and add a target to send somewhere of a different type. Changing the destination starts validation again. Passing `--field-map` replaces the whole map rather than adding to it. + +```sh +cloudcannon inboxes targets update --target-uuid 4f5a2c31-9d7e-4a6b-bb02-7c1f0a3e5d84 --block-spam +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--target-uuid ` | The inbox target UUID (required) | +| `--target ` | A new destination email address or webhook URL | +| `--payload-format ` | Override the payload format sent to the target | +| `--block-spam` | Hold back submissions flagged by spam detection | +| `--block-spam-list` | Hold back submissions matching the CloudCannon spam blocklist | +| `--field-map ` | Comma-separated hubspot_field=form_field pairs | + +--- + +### `inboxes targets remove --target-uuid ` + +Remove a target from an inbox. Requires `--force`. + +```sh +cloudcannon inboxes targets remove --target-uuid 4f5a2c31-9d7e-4a6b-bb02-7c1f0a3e5d84 --force +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--target-uuid ` | The inbox target UUID (required) | +| `--force` | Confirm the removal without being asked | + +--- + +### `inboxes targets revalidate --target-uuid ` + +Restart validation for a target that is not yet validated. Email targets are sent another verification email, and webhook targets are checked for their DNS TXT record again. A target that is already validated is left alone, so no second email is sent. + +```sh +cloudcannon inboxes targets revalidate --target-uuid 4f5a2c31-9d7e-4a6b-bb02-7c1f0a3e5d84 +``` + +**Flags** + +| Flag | Description | +|---|---| +| `--target-uuid ` | The inbox target UUID (required) | + +--- + ### `inboxes submissions list --inbox ` List submissions for an inbox. diff --git a/src/configure/utility.ts b/src/configure/utility.ts index 7595bb0..eff2d88 100644 --- a/src/configure/utility.ts +++ b/src/configure/utility.ts @@ -86,12 +86,23 @@ export function stringify(config: Record, format: Format): string { }); } +// citty gives a flag passed without a value as an empty string, which is never a value +// any of these settings can take. +export function blankFlag(value: unknown, flag: string): boolean { + if (typeof value === 'string' && !value.trim()) { + console.error(`${flag} needs a value.`); + return true; + } + + return false; +} + export function printJson(data: unknown): void { console.log(JSON.stringify(data, null, 2)); } export function printErrorJson(data: unknown): void { - console.log(text.bad(JSON.stringify(data, null, 2))); + console.error(styleText(['red'], JSON.stringify(data, null, 2), { stream: process.stderr })); } export const text = { diff --git a/src/inboxes.ts b/src/inboxes.ts index 1fe3144..e7d9859 100644 --- a/src/inboxes.ts +++ b/src/inboxes.ts @@ -1,5 +1,422 @@ +import type { ConnectInboxOptions, Inbox, UpdateInboxSettingsOptions } from '@cloudcannon/sdk'; import { defineCommand } from 'citty'; +import { blankFlag, printJson } from './configure/utility.ts'; +import { inboxArg, resolveInboxUuid } from './inboxes/resolve.ts'; import { inboxesSubmissionsCommand } from './inboxes/submissions.ts'; +import { inboxesTargetsCommand } from './inboxes/targets.ts'; +import { getSdkClient, handleAPIError } from './sdk-client.ts'; +import { resolveSiteUuid } from './sites/resolve.ts'; + +const CAPTCHA_TYPES = ['google', 'google_enterprise', 'hcaptcha', 'turnstile'] as const; + +const INVALID_COUNT = Symbol('invalid count'); + +function hasCaptchaProvider(inbox: Inbox): boolean { + return !!inbox.captcha_type && !!inbox.captcha_key && !!inbox.has_captcha_secret; +} + +const MAX_COUNT = 2_147_483_647; + +const INVALID_SCORE = Symbol('invalid score'); + +function parseScore(value: unknown, flag: string): number | undefined | typeof INVALID_SCORE { + if (value === undefined) { + return undefined; + } + + const score = typeof value === 'string' && value.trim() ? Number(value.trim()) : Number.NaN; + if (Number.isNaN(score) || score < 0 || score > 1) { + console.error(`${flag} needs a number from 0 to 1.`); + return INVALID_SCORE; + } + + return score; +} + +function parseCount(value: unknown, flag: string): number | undefined | typeof INVALID_COUNT { + if (value === undefined) { + return undefined; + } + + // A flag passed without a value arrives as an empty string, which Number() reads as 0, + // so digits are required rather than inferred. + if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) { + console.error(`${flag} needs a whole number of 0 or more.`); + return INVALID_COUNT; + } + + const count = Number(value.trim()); + if (count > MAX_COUNT) { + console.error(`${flag} cannot be more than ${MAX_COUNT}.`); + return INVALID_COUNT; + } + + return count; +} + +export const inboxesGetCommand = defineCommand({ + meta: { + name: 'get', + description: 'Get an inbox by name, ID, key, or UUID, including the key your forms post to.', + }, + args: inboxArg, + async run(ctx): Promise { + const client = await getSdkClient(); + const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); + if (!inboxUuid) { + process.exitCode = 1; + return; + } + + try { + const inbox = await client.inbox(inboxUuid).get(); + printJson(inbox); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + +export const inboxesUpdateCommand = defineCommand({ + meta: { + name: 'update', + description: "Update an inbox's settings.", + }, + args: { + ...inboxArg, + name: { + type: 'string', + description: 'A new name for the inbox', + valueHint: 'name', + }, + key: { + type: 'string', + description: 'A new key for the inbox, which changes where your forms post to', + valueHint: 'key', + }, + 'allow-uploads': { + type: 'boolean', + description: 'Accept file uploads from forms posting to this inbox', + }, + 'keep-form-hook-days': { + type: 'string', + description: 'The number of days to retain submissions', + valueHint: 'days', + }, + 'captcha-type': { + type: 'enum', + description: 'The captcha provider checking submissions to this inbox', + options: CAPTCHA_TYPES.slice(), + }, + 'captcha-key': { + type: 'string', + description: "The captcha provider's site key", + valueHint: 'key', + }, + 'captcha-secret': { + type: 'string', + description: + "The captcha provider's secret key, or a Google Cloud API key for google_enterprise. It is never shown again", + valueHint: 'secret', + }, + 'captcha-project-id': { + type: 'string', + description: + 'The Google Cloud project ID holding the reCAPTCHA key, required for google_enterprise', + valueHint: 'project', + }, + 'captcha-min-score': { + type: 'string', + description: + 'Reject reCAPTCHA tokens scoring below this, from 0 to 1. Applies to google (v3 only) and google_enterprise, and is 0.5 unless set', + valueHint: 'score', + }, + 'captcha-send-sitekey': { + type: 'boolean', + description: + 'Tell hCaptcha which site key to expect, so it rejects a token from a form using another of your site keys. On unless turned off', + negativeDescription: + 'Let hCaptcha accept a token from any site key on your account. Pass --captcha-send-sitekey to turn it back on', + }, + captcha: { + type: 'boolean', + description: + 'Pass --no-captcha to stop checking submissions to this inbox with a captcha, which clears the provider and its keys', + }, + }, + async run(ctx): Promise { + const blank = ( + [ + ['--name', ctx.args.name], + ['--key', ctx.args.key], + ['--captcha-key', ctx.args.captchaKey], + ['--captcha-secret', ctx.args.captchaSecret], + ['--captcha-project-id', ctx.args.captchaProjectId], + ] as const + ).some(([flag, value]) => blankFlag(value, flag)); + if (blank) { + process.exitCode = 1; + return; + } + + const body: UpdateInboxSettingsOptions = {}; + if (typeof ctx.args.name === 'string') { + body.name = ctx.args.name; + } + if (typeof ctx.args.key === 'string') { + body.key = ctx.args.key; + } + if (ctx.args.allowUploads !== undefined) { + body.allow_uploads = !!ctx.args.allowUploads; + } + if (ctx.args.captcha === true) { + console.error( + 'A captcha is turned on by naming its provider. Use --captcha-type with --captcha-key and --captcha-secret, and --captcha-project-id for google_enterprise.' + ); + process.exitCode = 1; + return; + } + if (ctx.args.captcha === false) { + if ( + ctx.args.captchaType || + ctx.args.captchaKey || + ctx.args.captchaSecret || + ctx.args.captchaProjectId || + ctx.args.captchaMinScore !== undefined || + ctx.args.captchaSendSitekey !== undefined + ) { + console.error('--no-captcha cannot be combined with the other captcha flags.'); + process.exitCode = 1; + return; + } + + body.captcha_type = null; + body.captcha_key = null; + body.captcha_secret = null; + body.captcha_config = {}; + } + if (typeof ctx.args.captchaType === 'string') { + body.captcha_type = ctx.args.captchaType; + } + if (typeof ctx.args.captchaKey === 'string') { + body.captcha_key = ctx.args.captchaKey; + } + if (typeof ctx.args.captchaSecret === 'string') { + body.captcha_secret = ctx.args.captchaSecret; + } + const minScore = parseScore(ctx.args.captchaMinScore, '--captcha-min-score'); + if (minScore === INVALID_SCORE) { + process.exitCode = 1; + return; + } + + const captchaConfig: Record = {}; + if (typeof ctx.args.captchaProjectId === 'string') { + captchaConfig.project_id = ctx.args.captchaProjectId; + } + if (minScore !== undefined) { + captchaConfig.min_score = minScore; + } + if (ctx.args.captchaSendSitekey !== undefined) { + captchaConfig.send_sitekey = !!ctx.args.captchaSendSitekey; + } + if (Object.keys(captchaConfig).length > 0) { + body.captcha_config = captchaConfig; + } + + const keepDays = parseCount(ctx.args.keepFormHookDays, '--keep-form-hook-days'); + if (keepDays === INVALID_COUNT) { + process.exitCode = 1; + return; + } + if (keepDays !== undefined) { + body.keep_form_hook_days = keepDays; + } + + if (Object.keys(body).length === 0) { + console.error( + 'Nothing to update. Provide --name, --key, --allow-uploads, --keep-form-hook-days, --captcha-type, --captcha-key, --captcha-secret, --captcha-project-id, --captcha-min-score, --captcha-send-sitekey, or --no-captcha.' + ); + process.exitCode = 1; + return; + } + + const client = await getSdkClient(); + const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); + if (!inboxUuid) { + process.exitCode = 1; + return; + } + + const inboxClient = client.inbox(inboxUuid); + + try { + const inbox = await inboxClient.update(body); + printJson(inbox); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + +export const inboxesDeleteCommand = defineCommand({ + meta: { + name: 'delete', + description: 'Delete an inbox, along with its submissions and targets.', + }, + args: { + ...inboxArg, + force: { + type: 'boolean', + description: 'Confirm the deletion without being asked', + }, + }, + async run(ctx): Promise { + if (!ctx.args.force) { + console.error( + 'Deleting an inbox also deletes its submissions and targets. Re-run with --force to confirm.' + ); + process.exitCode = 1; + return; + } + + const client = await getSdkClient(); + const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); + if (!inboxUuid) { + process.exitCode = 1; + return; + } + + try { + await client.inbox(inboxUuid).delete(); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + return; + } + + console.error('Inbox deleted.'); + }, +}); + +export const inboxesConnectCommand = defineCommand({ + meta: { + name: 'connect', + description: 'Connect a site to an inbox so its forms can post submissions.', + }, + args: { + ...inboxArg, + site: { + type: 'string', + description: 'The site name, ID, UUID, or domain', + valueHint: 'name|id|uuid|domain', + required: true, + }, + default: { + type: 'boolean', + description: "Make this the site's default inbox", + }, + 'require-captcha': { + type: 'boolean', + description: + "Reject submissions from this site without the inbox's captcha. Defaults to on when the inbox has a captcha provider, so add the provider's widget to your forms first", + }, + }, + async run(ctx): Promise { + const client = await getSdkClient(); + const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); + if (!inboxUuid) { + process.exitCode = 1; + return; + } + + const siteUuid = await resolveSiteUuid(client, ctx.args.site); + if (!siteUuid) { + process.exitCode = 1; + return; + } + + const body: ConnectInboxOptions = { inbox_uuid: inboxUuid }; + if (ctx.args.default !== undefined) { + body.default_inbox = !!ctx.args.default; + } + + try { + body.require_captcha = + ctx.args.requireCaptcha === undefined + ? hasCaptchaProvider(await client.inbox(inboxUuid).get()) + : !!ctx.args.requireCaptcha; + + const siteInbox = await client.site(siteUuid).connectInbox(body); + printJson(siteInbox); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + +export const inboxesDisconnectCommand = defineCommand({ + meta: { + name: 'disconnect', + description: 'Disconnect a site from an inbox so its forms stop posting submissions to it.', + }, + args: { + ...inboxArg, + site: { + type: 'string', + description: 'The site name, ID, UUID, or domain', + valueHint: 'name|id|uuid|domain', + required: true, + }, + force: { + type: 'boolean', + description: 'Confirm the disconnection without being asked', + }, + }, + async run(ctx): Promise { + if (!ctx.args.force) { + console.error( + 'Disconnecting stops this site posting submissions to the inbox. Re-run with --force to confirm.' + ); + process.exitCode = 1; + return; + } + + const client = await getSdkClient(); + const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); + if (!inboxUuid) { + process.exitCode = 1; + return; + } + + const siteUuid = await resolveSiteUuid(client, ctx.args.site); + if (!siteUuid) { + process.exitCode = 1; + return; + } + + try { + const connections = await client.site(siteUuid).getInboxConnections(); + const connection = connections.find((item) => item.inbox_uuid === inboxUuid); + if (!connection?.uuid) { + console.error('That site is not connected to that inbox.'); + process.exitCode = 1; + return; + } + + await client.siteInbox(connection.uuid).delete(); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + return; + } + + console.error('Site disconnected from inbox.'); + }, +}); export const inboxesCommand = defineCommand({ meta: { @@ -7,6 +424,12 @@ export const inboxesCommand = defineCommand({ description: 'Manage CloudCannon inboxes.', }, subCommands: { + get: inboxesGetCommand, + update: inboxesUpdateCommand, + delete: inboxesDeleteCommand, + connect: inboxesConnectCommand, + disconnect: inboxesDisconnectCommand, submissions: inboxesSubmissionsCommand, + targets: inboxesTargetsCommand, }, }); diff --git a/src/inboxes/resolve.ts b/src/inboxes/resolve.ts index be01603..ccacaa2 100644 --- a/src/inboxes/resolve.ts +++ b/src/inboxes/resolve.ts @@ -1,10 +1,19 @@ import type CloudCannonClient from '@cloudcannon/sdk'; -import type { ListOrgInboxesOptions } from '@cloudcannon/sdk'; -import { printJson } from '../configure/utility.ts'; +import type { Inbox, ListOrgInboxesOptions } from '@cloudcannon/sdk'; +import { printErrorJson } from '../configure/utility.ts'; import { handleAPIError } from '../sdk-client.ts'; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const inboxArg = { + inbox: { + type: 'string', + description: 'The inbox name, ID, key, or UUID', + valueHint: 'name|id|key|uuid', + required: true, + }, +} as const; + export async function resolveInboxUuid( client: CloudCannonClient, identifier: string @@ -13,35 +22,43 @@ export async function resolveInboxUuid( return identifier; } - const filters: ListOrgInboxesOptions['filters'] = {}; const idCandidate = Number(identifier); - - if (Number.isInteger(idCandidate) && idCandidate > 0) { - filters.id = idCandidate; - } else { - filters.search = identifier; - } + const isId = Number.isInteger(idCandidate) && idCandidate > 0; try { const orgs = await client.orgs(); - for (const org of orgs.items) { - const inboxes = await client.org(org.uuid).getInboxes({ - filters, - }); - - if (inboxes.items.length > 1) { - console.error(`Inbox identifier "${identifier}" is ambiguous. Potential matches are:`); - printJson(inboxes.items); - return; - } + const collect = async (filters: ListOrgInboxesOptions['filters']): Promise => { + const found: Inbox[] = []; + for (const org of orgs.items) { + if (!org.uuid) { + continue; + } - if (inboxes.items.length === 0) { - console.error(`No inbox found matching "${identifier}".`); - return; + const inboxes = await client.org(org.uuid).getInboxes({ filters }); + found.push(...inboxes.items); } + return found; + }; - return inboxes.items[0].uuid; + // An all-digits identifier is an ID first, but a key can be digits too, so a miss + // falls back to the wider search rather than reporting nothing found. + let candidateInboxes = await collect(isId ? { id: idCandidate } : { search: identifier }); + if (isId && candidateInboxes.length === 0) { + candidateInboxes = await collect({ search: identifier }); } + + if (candidateInboxes.length > 1) { + console.error(`Inbox identifier "${identifier}" is ambiguous. Potential matches are:`); + printErrorJson(candidateInboxes); + return; + } + + if (candidateInboxes.length === 0) { + console.error(`No inbox found matching "${identifier}".`); + return; + } + + return candidateInboxes[0].uuid; } catch (err: unknown) { handleAPIError(err); return; diff --git a/src/inboxes/submissions.ts b/src/inboxes/submissions.ts index d6a3115..e2a7579 100644 --- a/src/inboxes/submissions.ts +++ b/src/inboxes/submissions.ts @@ -1,9 +1,9 @@ import type { ListInboxSubmissionsOptions } from '@cloudcannon/sdk'; import { defineCommand } from 'citty'; import { printJson } from '../configure/utility.ts'; -import { buildListOptions, listFlagDefs } from '../list-options.ts'; +import { listFlagDefs, parseListOptions } from '../list-options.ts'; import { getSdkClient, handleAPIError } from '../sdk-client.ts'; -import { resolveInboxUuid } from './resolve.ts'; +import { inboxArg, resolveInboxUuid } from './resolve.ts'; export const inboxesSubmissionsListCommand = defineCommand({ meta: { @@ -11,15 +11,15 @@ export const inboxesSubmissionsListCommand = defineCommand({ description: 'List submissions for an inbox.', }, args: { - inbox: { - type: 'string', - description: 'The inbox name, ID, key, or UUID', - valueHint: 'name|id|key|uuid', - required: true, - }, + ...inboxArg, ...listFlagDefs, }, async run(ctx): Promise { + const options = parseListOptions(ctx.args); + if (!options) { + process.exitCode = 1; + return; + } const client = await getSdkClient(); const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); if (!inboxUuid) { @@ -27,7 +27,6 @@ export const inboxesSubmissionsListCommand = defineCommand({ return; } const inboxClient = client.inbox(inboxUuid); - const options = buildListOptions(ctx.args); try { const submissions = await inboxClient.getSubmissions(options as ListInboxSubmissionsOptions); printJson({ diff --git a/src/inboxes/targets.ts b/src/inboxes/targets.ts new file mode 100644 index 0000000..195683a --- /dev/null +++ b/src/inboxes/targets.ts @@ -0,0 +1,429 @@ +import type CloudCannonClient from '@cloudcannon/sdk'; +import type { + CreateInboxTargetOptions, + InboxTarget, + ListInboxTargetsOptions, + UpdateInboxTargetOptions, +} from '@cloudcannon/sdk'; +import { defineCommand } from 'citty'; +import { blankFlag, printJson } from '../configure/utility.ts'; +import { filterFlagDef, parsePairs } from '../list-options.ts'; +import { getSdkClient, handleAPIError } from '../sdk-client.ts'; +import { inboxArg, resolveInboxUuid } from './resolve.ts'; + +type TargetConfig = NonNullable; + +const TARGET_TYPES = [ + 'email', + 'slack', + 'zapier', + 'make', + 'ifttt', + 'discord', + 'teams', + 'hubspot', + 'n8n', + 'pipedream', + 'webhook', +] as const; + +const PAYLOAD_FORMATS = [ + 'email', + 'raw', + 'slack', + 'discord', + 'teams', + 'ifttt', + 'hubspot', + 'form_encoded', +] as const; + +const HUBSPOT_SUBMIT_URL = 'https://api.hsforms.com/submissions/v3/integration/submit'; + +const HUBSPOT_PORTAL_ID = /^[0-9]+$/; +const HUBSPOT_FORM_GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const CHALLENGE_RECORD_PREFIX = '_cloudcannon-challenge'; + +const targetUuidArg = { + 'target-uuid': { + type: 'string', + description: 'The inbox target UUID', + valueHint: 'uuid', + required: true, + }, +} as const; + +const targetConfigArgs = { + 'payload-format': { + type: 'enum', + description: + 'Override the payload format sent to the target. An email target accepts only email, a webhook target accepts any format except email, and every other type accepts its own format or raw', + options: PAYLOAD_FORMATS.slice(), + }, + 'block-spam': { + type: 'boolean', + description: 'Hold back submissions flagged by spam detection', + }, + 'block-spam-list': { + type: 'boolean', + description: 'Hold back submissions matching the CloudCannon spam blocklist', + }, + 'field-map': { + type: 'string', + description: + 'Comma-separated hubspot_field=form_field pairs, required when sending the HubSpot payload format. Replaces the whole map rather than adding to it', + valueHint: 'field=field,field=field', + }, +} as const; + +type TargetConfigArgs = Record; + +function buildTargetConfig(args: TargetConfigArgs): TargetConfig { + const config: TargetConfig = {}; + if (typeof args.payloadFormat === 'string') { + config.payload_format = args.payloadFormat; + } + if (args.blockSpam !== undefined) { + config.block_spam = !!args.blockSpam; + } + if (args.blockSpamList !== undefined) { + config.block_spam_list = !!args.blockSpamList; + } + if (typeof args.fieldMap === 'string') { + config.field_map = parsePairs(args.fieldMap, '--field-map'); + } + return config; +} + +function parseTargetConfig(args: TargetConfigArgs): TargetConfig | undefined { + try { + return buildTargetConfig(args); + } catch (err: unknown) { + console.error(err instanceof Error ? err.message : String(err)); + return undefined; + } +} + +function hostnameOf(target: string): string | undefined { + try { + return new URL(target).hostname; + } catch { + return undefined; + } +} + +async function reportPendingValidation( + client: CloudCannonClient, + target: InboxTarget +): Promise { + if (target.validated !== false) { + return; + } + + if (target.target_type === 'email') { + console.error( + `This target is not forwarding yet. CloudCannon has emailed ${target.target}, and forwarding starts once that link is opened. To send that email again, run:` + ); + console.error(` cloudcannon inboxes targets revalidate --target-uuid ${target.uuid}`); + return; + } + + const host = target.target ? hostnameOf(target.target) : undefined; + if (!host) { + return; + } + + let token: string | undefined; + if (target.inbox_uuid) { + try { + token = (await client.inbox(target.inbox_uuid).get()).organisation_uuid; + } catch { + token = undefined; + } + } + + console.error( + `This target is not forwarding yet. Add a DNS TXT record at ${CHALLENGE_RECORD_PREFIX}.${host}, then run:` + ); + console.error(` cloudcannon inboxes targets revalidate --target-uuid ${target.uuid}`); + + if (token) { + console.error(`The value of that record is your Organization UUID, ${token}.`); + } else { + console.error( + 'The value of that record is your Organization UUID, which this command could not read. Run `cloudcannon inboxes get` and use the organisation_uuid it prints.' + ); + } +} + +export const inboxesTargetsListCommand = defineCommand({ + meta: { + name: 'list', + description: 'List the targets an inbox forwards submissions to.', + }, + args: { + ...inboxArg, + ...filterFlagDef, + }, + async run(ctx): Promise { + const options: ListInboxTargetsOptions = {}; + if (ctx.args.filter !== undefined) { + try { + options.filters = parsePairs(String(ctx.args.filter), '--filter'); + } catch (err: unknown) { + console.error(err instanceof Error ? err.message : String(err)); + process.exitCode = 1; + return; + } + } + + const client = await getSdkClient(); + const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); + if (!inboxUuid) { + process.exitCode = 1; + return; + } + + try { + const targets = await client.inbox(inboxUuid).getTargets(options); + printJson(targets); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + +export const inboxesTargetsAddCommand = defineCommand({ + meta: { + name: 'add', + description: 'Add a target that an inbox forwards submissions to.', + }, + args: { + ...inboxArg, + type: { + type: 'enum', + description: 'The integration this target sends to', + options: [...TARGET_TYPES], + required: true, + }, + target: { + type: 'string', + description: 'The destination email address or webhook URL, not used by HubSpot targets', + valueHint: 'email|url', + }, + 'portal-id': { + type: 'string', + description: 'The HubSpot portal ID, used instead of --target for HubSpot targets', + valueHint: 'id', + }, + 'form-guid': { + type: 'string', + description: 'The HubSpot form GUID, used instead of --target for HubSpot targets', + valueHint: 'guid', + }, + ...targetConfigArgs, + }, + async run(ctx): Promise { + const targetType = ctx.args.type; + if (!targetType) { + console.error(`--type is required. Choose one of: ${TARGET_TYPES.join(', ')}.`); + process.exitCode = 1; + return; + } + + let target: string; + + if (targetType === 'hubspot') { + const portalId = typeof ctx.args.portalId === 'string' ? ctx.args.portalId.trim() : ''; + const formGuid = typeof ctx.args.formGuid === 'string' ? ctx.args.formGuid.trim() : ''; + if (!portalId || !formGuid) { + console.error('HubSpot targets need both --portal-id and --form-guid.'); + process.exitCode = 1; + return; + } + if (!HUBSPOT_PORTAL_ID.test(portalId)) { + console.error('--portal-id must be a HubSpot portal ID, which is digits only.'); + process.exitCode = 1; + return; + } + if (!HUBSPOT_FORM_GUID.test(formGuid)) { + console.error('--form-guid must be a HubSpot form GUID, which is a UUID.'); + process.exitCode = 1; + return; + } + if (ctx.args.payloadFormat !== 'raw' && ctx.args.fieldMap === undefined) { + console.error( + 'HubSpot targets need --field-map, which maps HubSpot field names to your form field names.' + ); + process.exitCode = 1; + return; + } + target = `${HUBSPOT_SUBMIT_URL}/${portalId}/${formGuid}`; + } else { + if (!ctx.args.target) { + console.error(`--target is required for ${targetType} targets.`); + process.exitCode = 1; + return; + } + target = ctx.args.target; + } + + const config = parseTargetConfig(ctx.args); + if (!config) { + process.exitCode = 1; + return; + } + const body: CreateInboxTargetOptions = { target_type: targetType, target }; + if (Object.keys(config).length > 0) { + body.config = config; + } + + const client = await getSdkClient(); + const inboxUuid = await resolveInboxUuid(client, ctx.args.inbox); + if (!inboxUuid) { + process.exitCode = 1; + return; + } + + try { + const created = await client.inbox(inboxUuid).createTarget(body); + printJson(created); + await reportPendingValidation(client, created); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + +export const inboxesTargetsUpdateCommand = defineCommand({ + meta: { + name: 'update', + description: + "Update an inbox target's destination and options. A target's type cannot be changed, so remove and add a target to send somewhere of a different type.", + }, + args: { + ...targetUuidArg, + target: { + type: 'string', + description: + 'A new destination email address or webhook URL. Changing this starts validation again, so the target stops forwarding until it passes', + valueHint: 'email|url', + }, + ...targetConfigArgs, + }, + async run(ctx): Promise { + const config = parseTargetConfig(ctx.args); + if (!config) { + process.exitCode = 1; + return; + } + if (blankFlag(ctx.args.target, '--target')) { + process.exitCode = 1; + return; + } + + const newTarget = typeof ctx.args.target === 'string' ? ctx.args.target : undefined; + if (Object.keys(config).length === 0 && newTarget === undefined) { + console.error( + 'Nothing to update. Provide --target, --payload-format, --block-spam, --block-spam-list, or --field-map.' + ); + process.exitCode = 1; + return; + } + + const client = await getSdkClient(); + const targetClient = client.inboxTarget(String(ctx.args.targetUuid)); + + try { + const body: UpdateInboxTargetOptions = {}; + if (newTarget !== undefined) { + body.target = newTarget; + } + + if (Object.keys(config).length > 0) { + const existing = await targetClient.get(); + body.config = { ...(existing.config ?? {}), ...config }; + } + + const updated = await targetClient.update(body); + printJson(updated); + await reportPendingValidation(client, updated); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + +export const inboxesTargetsRemoveCommand = defineCommand({ + meta: { + name: 'remove', + description: 'Remove a target from an inbox.', + }, + args: { + ...targetUuidArg, + force: { + type: 'boolean', + description: 'Confirm the removal without being asked', + }, + }, + async run(ctx): Promise { + if (!ctx.args.force) { + console.error( + 'Removing a target stops it receiving submissions. Re-run with --force to confirm.' + ); + process.exitCode = 1; + return; + } + + const client = await getSdkClient(); + + try { + await client.inboxTarget(String(ctx.args.targetUuid)).delete(); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + return; + } + + console.error('Target removed.'); + }, +}); + +export const inboxesTargetsRevalidateCommand = defineCommand({ + meta: { + name: 'revalidate', + description: + 'Restart validation for a target. Email targets are sent another verification email, and webhook targets are checked for their DNS TXT record again.', + }, + args: targetUuidArg, + async run(ctx): Promise { + const client = await getSdkClient(); + + try { + const target = await client.inboxTarget(String(ctx.args.targetUuid)).revalidate(); + printJson(target); + await reportPendingValidation(client, target); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + +export const inboxesTargetsCommand = defineCommand({ + meta: { + name: 'targets', + description: 'Manage the targets an inbox forwards submissions to.', + }, + subCommands: { + list: inboxesTargetsListCommand, + add: inboxesTargetsAddCommand, + update: inboxesTargetsUpdateCommand, + remove: inboxesTargetsRemoveCommand, + revalidate: inboxesTargetsRevalidateCommand, + }, +}); diff --git a/src/index.ts b/src/index.ts index 285d8ca..fd5ebe7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,22 @@ #!/usr/bin/env node import tab from '@bomb.sh/tab/citty'; -import { runMain } from 'citty'; +import { renderUsage, runMain } from 'citty'; import { main } from './main.ts'; +const HELP_FLAGS = new Set(['--help', '-h']); + await tab(main); -runMain(main); + +// citty prints usage on argument errors as well as for --help. Only the latter is a +// successful result, so the rest goes to stderr and leaves stdout as JSON or empty. +runMain(main, { + showUsage: async (cmd, parent): Promise => { + const usage = `${await renderUsage(cmd, parent)}\n`; + if (process.argv.slice(2).some((arg) => HELP_FLAGS.has(arg))) { + console.log(usage); + } else { + console.error(usage); + } + }, +}); diff --git a/src/list-options.ts b/src/list-options.ts index 7618764..f3dc6e5 100644 --- a/src/list-options.ts +++ b/src/list-options.ts @@ -1,3 +1,11 @@ +export const filterFlagDef = { + filter: { + type: 'string', + description: 'Comma-separated key=value pairs to filter by', + valueHint: 'key=value,key=value', + }, +} as const; + export const listFlagDefs = { page: { type: 'string', @@ -19,11 +27,7 @@ export const listFlagDefs = { description: 'Sort direction (ASC or DESC)', valueHint: 'ASC|DESC', }, - filter: { - type: 'string', - description: 'Comma-separated key=value pairs to filter by', - valueHint: 'key=value,key=value', - }, + ...filterFlagDef, } as const; export type ListArgs = { @@ -53,24 +57,41 @@ export function buildListOptions(args: ListArgs): Record { options.sort_direction = dir; } if (args.filter !== undefined) { - const filters: Record = {}; - for (const pair of String(args.filter).split(',')) { - const trimmed = pair.trim(); - if (!trimmed) { - continue; - } - const eq = trimmed.indexOf('='); - if (eq === -1) { - throw new Error(`--filter entry "${trimmed}" must be in key=value form.`); - } - const key = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1).trim(); - if (!key) { - throw new Error(`--filter entry "${trimmed}" must have a key.`); - } - filters[key] = value; - } - options.filters = filters; + options.filters = parsePairs(String(args.filter), '--filter'); } return options; } + +export function parseListOptions(args: ListArgs): Record | undefined { + try { + return buildListOptions(args); + } catch (err: unknown) { + console.error(err instanceof Error ? err.message : String(err)); + return undefined; + } +} + +export function parsePairs(value: string, flag: string): Record { + if (!value.trim()) { + throw new Error(`${flag} needs at least one key=value pair.`); + } + + const pairs: Record = {}; + for (const pair of value.split(',')) { + const trimmed = pair.trim(); + if (!trimmed) { + continue; + } + const eq = trimmed.indexOf('='); + if (eq === -1) { + throw new Error(`${flag} entry "${trimmed}" must be in key=value form.`); + } + const key = trimmed.slice(0, eq).trim(); + const pairValue = trimmed.slice(eq + 1).trim(); + if (!key) { + throw new Error(`${flag} entry "${trimmed}" must have a key.`); + } + pairs[key] = pairValue; + } + return pairs; +} diff --git a/src/orgs.ts b/src/orgs.ts index f8f74c2..8c7a13b 100644 --- a/src/orgs.ts +++ b/src/orgs.ts @@ -1,7 +1,7 @@ import type { ListOrgsOptions } from '@cloudcannon/sdk'; import { defineCommand } from 'citty'; import { printJson } from './configure/utility.ts'; -import { buildListOptions, listFlagDefs } from './list-options.ts'; +import { listFlagDefs, parseListOptions } from './list-options.ts'; import { orgsInboxesCommand } from './orgs/inboxes.ts'; import { resolveOrg } from './orgs/resolve.ts'; import { orgsSitesCommand } from './orgs/sites.ts'; @@ -14,8 +14,12 @@ export const orgsListCommand = defineCommand({ }, args: listFlagDefs, async run(ctx): Promise { + const options = parseListOptions(ctx.args); + if (!options) { + process.exitCode = 1; + return; + } const client = await getSdkClient(); - const options = buildListOptions(ctx.args); try { const orgs = await client.orgs(options as ListOrgsOptions); printJson({ diff --git a/src/orgs/inboxes.ts b/src/orgs/inboxes.ts index 3ce9017..da12f5d 100644 --- a/src/orgs/inboxes.ts +++ b/src/orgs/inboxes.ts @@ -1,7 +1,7 @@ import type { ListOrgInboxesOptions } from '@cloudcannon/sdk'; import { defineCommand } from 'citty'; import { printJson } from '../configure/utility.ts'; -import { buildListOptions, listFlagDefs } from '../list-options.ts'; +import { listFlagDefs, parseListOptions } from '../list-options.ts'; import { getSdkClient, handleAPIError } from '../sdk-client.ts'; import { resolveOrg } from './resolve.ts'; @@ -19,6 +19,11 @@ export const orgsInboxesListCommand = defineCommand({ ...listFlagDefs, }, async run(ctx): Promise { + const options = parseListOptions(ctx.args); + if (!options) { + process.exitCode = 1; + return; + } const client = await getSdkClient(); const org = await resolveOrg(client, ctx.args.org); if (!org) { @@ -26,7 +31,6 @@ export const orgsInboxesListCommand = defineCommand({ return; } const orgClient = client.org(org.uuid); - const options = buildListOptions(ctx.args); try { const inboxes = await orgClient.getInboxes(options as ListOrgInboxesOptions); printJson({ @@ -42,6 +46,62 @@ export const orgsInboxesListCommand = defineCommand({ }, }); +function toKey(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export const orgsInboxesCreateCommand = defineCommand({ + meta: { + name: 'create', + description: 'Create an inbox for an Organization.', + }, + args: { + org: { + type: 'string', + description: 'The Organization name, ID, or UUID', + valueHint: 'name|id|uuid', + }, + name: { + type: 'string', + description: 'The inbox name', + valueHint: 'name', + required: true, + }, + key: { + type: 'string', + description: 'The inbox key your forms post to, which defaults to a slug of the name', + valueHint: 'key', + }, + }, + async run(ctx): Promise { + const key = + typeof ctx.args.key === 'string' && ctx.args.key ? ctx.args.key : toKey(ctx.args.name); + if (!key) { + console.error('Could not build a key from the inbox name. Provide one with --key.'); + process.exitCode = 1; + return; + } + + const client = await getSdkClient(); + const org = await resolveOrg(client, ctx.args.org); + if (!org) { + process.exitCode = 1; + return; + } + + try { + const inbox = await client.org(org.uuid).createInbox({ name: ctx.args.name, key }); + printJson(inbox); + } catch (err: unknown) { + handleAPIError(err); + process.exitCode = 1; + } + }, +}); + export const orgsInboxesCommand = defineCommand({ meta: { name: 'inboxes', @@ -49,5 +109,6 @@ export const orgsInboxesCommand = defineCommand({ }, subCommands: { list: orgsInboxesListCommand, + create: orgsInboxesCreateCommand, }, }); diff --git a/src/orgs/resolve.ts b/src/orgs/resolve.ts index 0f0229b..964f084 100644 --- a/src/orgs/resolve.ts +++ b/src/orgs/resolve.ts @@ -1,6 +1,6 @@ import type CloudCannonClient from '@cloudcannon/sdk'; import type { ListOrgsOptions, Org } from '@cloudcannon/sdk'; -import { printJson } from '../configure/utility.ts'; +import { printErrorJson } from '../configure/utility.ts'; import { handleAPIError } from '../sdk-client.ts'; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -15,6 +15,16 @@ export async function resolveOrg( if (orgs.items.length === 1) { return orgs.items[0]; } + + if (orgs.items.length === 0) { + console.error('This account is not a member of any Organization.'); + return; + } + + console.error( + 'This account is a member of more than one Organization. Choose one with --org:' + ); + printErrorJson(orgs.items); return; } catch (err: unknown) { handleAPIError(err); @@ -45,7 +55,7 @@ export async function resolveOrg( if (orgs.items.length > 1) { console.error(`Org identifier "${identifier}" is ambiguous. Potential matches are:`); - printJson(orgs.items); + printErrorJson(orgs.items); return; } diff --git a/src/orgs/sites.ts b/src/orgs/sites.ts index d60dbdd..188b269 100644 --- a/src/orgs/sites.ts +++ b/src/orgs/sites.ts @@ -1,7 +1,7 @@ import type { ListOrgSitesOptions } from '@cloudcannon/sdk'; import { defineCommand } from 'citty'; import { printJson } from '../configure/utility.ts'; -import { buildListOptions, listFlagDefs } from '../list-options.ts'; +import { listFlagDefs, parseListOptions } from '../list-options.ts'; import { getSdkClient, handleAPIError } from '../sdk-client.ts'; import { resolveOrg } from './resolve.ts'; @@ -19,6 +19,11 @@ export const orgsSitesListCommand = defineCommand({ ...listFlagDefs, }, async run(ctx): Promise { + const options = parseListOptions(ctx.args); + if (!options) { + process.exitCode = 1; + return; + } const client = await getSdkClient(); const org = await resolveOrg(client, ctx.args.org as string | undefined); if (!org) { @@ -26,7 +31,6 @@ export const orgsSitesListCommand = defineCommand({ return; } const orgClient = client.org(org.uuid); - const options = buildListOptions(ctx.args); try { const sites = await orgClient.sites(options as ListOrgSitesOptions); printJson({ diff --git a/src/sdk-client.ts b/src/sdk-client.ts index 7e89a21..e9b6490 100644 --- a/src/sdk-client.ts +++ b/src/sdk-client.ts @@ -55,6 +55,27 @@ if (dataDir) { await mkdir(dataDir, { recursive: true }); } +const SENSITIVE_KEY = /api[-_]?key|authorization|secret|password|token/i; + +// Only ever applied to what the CLI sent, never to what the API sent back: a 422 names +// the field and explains why, and an agent needs that text to correct itself. +function redact(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redact); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + SENSITIVE_KEY.test(key) ? '[redacted]' : redact(entry), + ]) + ); + } + + return value; +} + export function handleAPIError(err: unknown): void { if (err instanceof AuthenticationError) { console.error( @@ -62,7 +83,7 @@ export function handleAPIError(err: unknown): void { ); const details: { authHeaders: Record; errors?: unknown; options?: unknown } = { - authHeaders: err.authHeaders, + authHeaders: redact(err.authHeaders) as Record, }; if (err.errors) { @@ -70,7 +91,7 @@ export function handleAPIError(err: unknown): void { } if (err.options) { - details.options = err.options; + details.options = redact(err.options); } printErrorJson(details); @@ -91,10 +112,16 @@ export function handleAPIError(err: unknown): void { } if (err.options) { - details.options = err.options; + details.options = redact(err.options); } printErrorJson(details); + + if (err.status === 403) { + console.error( + 'A 403 can mean the record does not exist, as well as that your access key cannot reach it.' + ); + } } else { throw err; } @@ -176,7 +203,7 @@ export async function getSdkClient(): Promise { } else if (apiKey) { options = { key: apiKey, client }; } else { - console.log( + console.error( `You must log in to run this command. Either run ${text.em('cloudcannon login')} to authorize with your CloudCannon account, or provide an API key through the CLOUDCANNON_API_KEY environment variable.` ); process.exit(1); diff --git a/src/sites/builds.ts b/src/sites/builds.ts index 8e238dd..815a9f2 100644 --- a/src/sites/builds.ts +++ b/src/sites/builds.ts @@ -1,7 +1,7 @@ import type { ListSiteBuildsOptions } from '@cloudcannon/sdk'; import { defineCommand } from 'citty'; import { printJson } from '../configure/utility.ts'; -import { buildListOptions, listFlagDefs } from '../list-options.ts'; +import { listFlagDefs, parseListOptions } from '../list-options.ts'; import { getSdkClient, handleAPIError } from '../sdk-client.ts'; import { resolveSiteUuid } from './resolve.ts'; @@ -20,6 +20,11 @@ export const sitesBuildsListCommand = defineCommand({ ...listFlagDefs, }, async run(ctx): Promise { + const options = parseListOptions(ctx.args); + if (!options) { + process.exitCode = 1; + return; + } const client = await getSdkClient(); const siteUuid = await resolveSiteUuid(client, ctx.args.site); if (!siteUuid) { @@ -27,7 +32,6 @@ export const sitesBuildsListCommand = defineCommand({ return; } const site = client.site(siteUuid); - const options = buildListOptions(ctx.args); try { const builds = await site.getBuilds(options as ListSiteBuildsOptions); printJson({ diff --git a/src/sites/resolve.ts b/src/sites/resolve.ts index db95f96..5560d1c 100644 --- a/src/sites/resolve.ts +++ b/src/sites/resolve.ts @@ -1,6 +1,6 @@ import type CloudCannonClient from '@cloudcannon/sdk'; import type { ListOrgSitesOptions } from '@cloudcannon/sdk'; -import { printJson } from '../configure/utility.ts'; +import { printErrorJson } from '../configure/utility.ts'; import { handleAPIError } from '../sdk-client.ts'; const STABLE_DOMAIN_SUFFIX = '.cloudvent.net'; @@ -41,7 +41,7 @@ export async function resolveSiteUuid( if (candidateSites.length > 1) { console.error(`Site identifier "${identifier}" is ambiguous. Potential matches are:`); - printJson(candidateSites); + printErrorJson(candidateSites); return; }