diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a58020..eabbdad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to `@gethookmyapp/cli` are documented here. ## Unreleased +### Added + +- `hookmyapp alerts phone status|set|verify`: set the phone number HookMyApp texts when something breaks. Per user, not per workspace (AIT-376). + ## 0.14.11 — 2026-08-08 ### Added diff --git a/README.md b/README.md index 58feec7..cb7482f 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,31 @@ Move a channel between workspaces or customers in the same organization: hookmyapp channels move ch_xxxxxxxx "Acme Corp" # target by name or ws_ id ``` +## Alert phone + +Where we reach you if something stops working. + +```bash +# See your alert phone and what it receives +hookmyapp alerts phone status + +# Add or change it (international format) +hookmyapp alerts phone set +14155552671 + +# Get the code by SMS instead of WhatsApp +hookmyapp alerts phone set +14155552671 --sms + +# Finish verification with the code we sent +hookmyapp alerts phone verify 123456 + +# Remove your alert phone +hookmyapp alerts phone remove +``` + +When delivery succeeds, we send a 6-digit code to confirm the number, and `set` asks for it. If delivery fails, `set` says so and exits without asking; try again in a moment. Without a terminal (CI, redirected stdin) there is no prompt, so run `set --json` and finish with `alerts phone verify `. Already have a code from an earlier `set`? `set --code 123456` verifies it directly. + +Alerts are on once the number is verified. + ## JSON output and global flags Four global flags apply to every command: diff --git a/src/commands/__tests__/alerts.test.ts b/src/commands/__tests__/alerts.test.ts new file mode 100644 index 0000000..4f4e735 --- /dev/null +++ b/src/commands/__tests__/alerts.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../api/client.js', () => ({ + apiClient: vi.fn(), + setWorkspaceContext: vi.fn(), +})); + +import { apiClient } from '../../api/client.js'; +import { alertPhoneRemove, alertPhoneSet, alertPhoneStatus, alertPhoneVerify } from '../alerts.js'; + +const VERIFIED = { + phone: '+141•••2671', + verified: true, + consents: { operational: true, product: false, marketing: false }, + channelPreference: 'whatsapp', +}; + +describe('alerts phone', () => { + let logs: string[]; + + beforeEach(() => { + vi.mocked(apiClient).mockReset(); + logs = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('When no phone is verified, then status points at the set command', async () => { + // Arrange + vi.mocked(apiClient).mockResolvedValueOnce({ ...VERIFIED, phone: null, verified: false }); + // Act + await alertPhoneStatus({ json: false }); + // Assert + expect(logs.join('\n')).toContain('alerts phone set'); + }); + + test('When the number is not international format, then it is rejected before any call', async () => { + // Act + Assert — a national number would be sent to the wrong country. + await expect(alertPhoneSet('0545434384')).rejects.toThrow(/international format/); + expect(apiClient).not.toHaveBeenCalled(); + }); + + test('When the number is a bare plus or over-length, then it is rejected', async () => { + // Act + Assert — CodeRabbit: startsWith('+') let these through. + await expect(alertPhoneSet('+')).rejects.toThrow(/international format/); + await expect(alertPhoneSet('+abc')).rejects.toThrow(/international format/); + await expect(alertPhoneSet('+1234567890123456')).rejects.toThrow(/international format/); + expect(apiClient).not.toHaveBeenCalled(); + }); + + test('When there is no TTY and no --json or --code, then it refuses before sending', async () => { + // Act + Assert — non-interactive, so no code is sent and no apiClient call. + await expect(alertPhoneSet('+14155552671', { interactive: false })).rejects.toThrow(/interactive terminal/); + expect(apiClient).not.toHaveBeenCalled(); + }); + + test('When --code is malformed, then no code is sent', async () => { + // Act + Assert — Codex: the send used to go out first, burning quota for a + // code that could only be rejected locally. + await expect(alertPhoneSet('+14155552671', { code: '12ab' })).rejects.toThrow(/6 digits/); + expect(apiClient).not.toHaveBeenCalled(); + }); + + test('When consent flags are omitted, then only operational consent is sent', async () => { + // Arrange — marketing consent must never be assumed from a bare command. + vi.mocked(apiClient).mockResolvedValueOnce({ delivery: 'sent' }); + // Act + await alertPhoneSet('+14155552671', { json: true }); + // Assert + const body = JSON.parse(String(vi.mocked(apiClient).mock.calls[0][1]?.body)); + expect(body).toMatchObject({ consentOperational: true, consentProduct: true, consentMarketing: true }); + }); + + test('When delivery fails, then it does not ask for a code', async () => { + // Arrange + vi.mocked(apiClient).mockResolvedValueOnce({ delivery: 'unavailable' }); + // Act — interactive so the run reaches the delivery check, not the TTY guard. + await alertPhoneSet('+14155552671', { json: false, interactive: true }); + // Assert — one call only: no verify attempt against a code nobody received. + expect(apiClient).toHaveBeenCalledTimes(1); + expect(logs.join('\n')).toContain('could not deliver'); + }); + + test('When a code is supplied, then set verifies directly without starting a new challenge', async () => { + // Arrange + vi.mocked(apiClient).mockResolvedValueOnce(VERIFIED); + // Act + await alertPhoneSet('+14155552671', { code: '123456', json: false }); + // Assert — one call only: a new challenge would supersede the one the code belongs to. + expect(apiClient).toHaveBeenCalledTimes(1); + expect(vi.mocked(apiClient).mock.calls[0][0]).toBe('/auth/phone/verify'); + }); + + test('When the code is not six digits, then verify rejects it locally', async () => { + // Act + Assert + await expect(alertPhoneVerify('12ab')).rejects.toThrow(/6 digits/); + expect(apiClient).not.toHaveBeenCalled(); + }); +}); + +describe('alerts phone remove', () => { + test('When --json, then it deletes without a prompt and prints the status', async () => { + vi.mocked(apiClient).mockResolvedValueOnce({ + phone: null, + verified: false, + consents: { operational: true, product: false, marketing: false }, + channelPreference: 'whatsapp', + }); + await alertPhoneRemove({ json: true }); + expect(apiClient).toHaveBeenCalledWith('/auth/phone', { method: 'DELETE' }); + }); +}); diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts new file mode 100644 index 0000000..0fb28c3 --- /dev/null +++ b/src/commands/alerts.ts @@ -0,0 +1,276 @@ +import { Command } from 'commander'; +import { apiClient } from '../api/client.js'; +import { output } from '../output/format.js'; +import { c } from '../output/color.js'; +import { ValidationError } from '../output/error.js'; +import { addExamples } from '../output/help.js'; +import { cliCommandPrefix } from '../output/cli-self.js'; + +// AIT-376 — the caller's OWN alert phone: where HookMyApp texts them when +// something breaks. User-scoped, never workspace-scoped, and never settable +// for another member — the /auth/phone routes act on the authenticated user. +// +// Mirrors the MCP tools (get_alert_phone_status / set_alert_phone / +// verify_alert_phone) so an agent and a human reach the same state the same +// way. + +interface AlertPhoneStatus { + phone: string | null; + verified: boolean; + consents: { operational: boolean; product: boolean; marketing: boolean }; + channelPreference: string; +} + +export async function alertPhoneStatus(opts: { json?: boolean } = {}): Promise { + const status = (await apiClient('/auth/phone')) as AlertPhoneStatus; + + if (opts.json) { + output(status, { json: true }); + return; + } + + if (!status.verified) { + console.log( + c.warn('No alert phone verified.') + + `\nRun \`${cliCommandPrefix()} alerts phone set +14155552671\` so we can reach you when something breaks.`, + ); + return; + } + + output( + { + phone: status.phone, + delivery: status.channelPreference, + 'problem alerts': status.consents.operational ? 'on' : 'off', + 'product news': status.consents.product ? 'on' : 'off', + offers: status.consents.marketing ? 'on' : 'off', + }, + { json: false, kind: 'read' }, + ); +} + +/** Verifying the number consents to all alert categories; opt-out lives in the web app. */ +export async function alertPhoneSet( + phone: string, + opts: { json?: boolean; sms?: boolean; code?: string; interactive?: boolean } = {}, +): Promise { + // E.164: '+' then 1-15 digits, first non-zero. Rejects '+', '+abc', and + // over-length values that a bare startsWith('+') would wave through into a + // registration request. + if (!/^\+[1-9]\d{1,14}$/.test(phone ?? '')) { + throw new ValidationError( + `Phone must be in international format, e.g. +14155552671 (got "${phone}").`, + 'ALERT_PHONE_FORMAT', + ); + } + + // A malformed --code can only fail, so check it BEFORE the challenge starts: + // otherwise the send goes out, burns delivery quota and supersedes any live + // challenge, only for alertPhoneVerify to reject the code locally a moment + // later. + if (opts.code !== undefined && !/^\d{6}$/.test(opts.code.trim())) { + throw new ValidationError('The verification code is 6 digits.', 'ALERT_PHONE_CODE_FORMAT'); + } + + // A supplied code belongs to a challenge that was already sent. Starting a + // new challenge would supersede that one and invalidate the very code we + // were handed — so route straight to verification instead. + if (opts.code) { + await alertPhoneVerify(opts.code.trim(), { json: opts.json }); + return; + } + + // A prompt needs a TTY. Without one (CI, redirected stdin) and without --json + // or --code, we would send a code and then block forever on input(). Refuse + // before the send so no code — and no quota — is spent. + const interactive = opts.interactive ?? Boolean(process.stdin.isTTY); + if (!opts.json && !interactive) { + throw new ValidationError( + 'No interactive terminal. Run with --json to start, then `alerts phone verify `, ' + + 'or pass --code .', + 'ALERT_PHONE_NO_TTY', + ); + } + + if (!opts.json) { + console.log( + 'By adding your number, you consent to us contacting you when anything\n' + + 'breaks or fails, as well as with product news and occasional marketing.\n' + + 'Opt out anytime.', + ); + } + + const start = (await apiClient('/auth/phone', { + method: 'POST', + body: JSON.stringify({ + phone, + consentOperational: true, + consentProduct: true, + consentMarketing: true, + channelPreference: opts.sms ? 'sms' : 'whatsapp', + }), + })) as { delivery?: string }; + + if (start.delivery !== 'sent') { + // The code was NOT delivered — say so instead of asking for a code that + // never arrived. The challenge stays valid, so `alerts phone verify` still + // works if it shows up late. + if (opts.json) { + output({ delivery: start.delivery ?? 'unavailable', verified: false }, { json: true }); + return; + } + console.log( + c.warn('We could not deliver a code to that number right now.') + + `\nNothing was sent. Try again in a moment, or check the number.`, + ); + return; + } + + if (opts.json) { + // A prompt cannot run under --json. Report that the code is out and let + // the caller finish with `verify`. + output({ delivery: 'sent', verified: false, next: 'alerts phone verify ' }, { json: true }); + return; + } + + console.log(`We sent a 6-digit code to ${phone}.`); + const { input } = await import('@inquirer/prompts'); + const code = await input({ + message: 'Enter the code:', + validate: (v) => (/^\d{6}$/.test(v.trim()) ? true : 'The code is 6 digits.'), + }); + await alertPhoneVerify(code.trim(), { json: false }); +} + +export async function alertPhoneVerify(code: string, opts: { json?: boolean } = {}): Promise { + if (!/^\d{6}$/.test(code?.trim() ?? '')) { + throw new ValidationError('The verification code is 6 digits.', 'ALERT_PHONE_CODE_FORMAT'); + } + + const status = (await apiClient('/auth/phone/verify', { + method: 'POST', + body: JSON.stringify({ code: code.trim() }), + })) as AlertPhoneStatus; + + if (opts.json) { + output(status, { json: true }); + return; + } + console.log(c.success(`Verified. Alerts go to ${status.phone}.`)); +} + +export async function alertPhoneRemove(opts: { json?: boolean; yes?: boolean } = {}): Promise { + if (!opts.yes && !opts.json) { + const { confirm } = await import('@inquirer/prompts'); + const ok = await confirm({ + message: + 'If you remove your number, we will not be able to text you when something breaks. Remove it?', + default: false, + }); + if (!ok) { + console.log('Aborted.'); + return; + } + } + const status = (await apiClient('/auth/phone', { method: 'DELETE' })) as AlertPhoneStatus | undefined; + if (opts.json) { + // A 204 leaves status undefined; print valid JSON either way. + output(status ?? null, { json: true }); + return; + } + console.log(c.success('Alert phone removed.')); +} + +export function registerAlertsCommand(_program: Command): void { + const alerts = _program.command('alerts').description('Where we reach you when something breaks'); + const phone = alerts.command('phone').description('Your alert phone number'); + + const statusCmd = phone + .command('status', { isDefault: true }) + .description('Show your alert phone and what it receives') + .action(async () => { + const { program: rootProgram } = await import('../index.js'); + await alertPhoneStatus({ json: !!rootProgram.opts().json }); + }); + + const setCmd = phone + .command('set ') + .description('Add or change your alert phone (international format, e.g. +14155552671)') + .option('--sms', 'Deliver by SMS instead of WhatsApp') + .option('--code ', 'Skip the prompt and verify with this code') + .action(async (phoneArg: string, cmdOpts: { sms?: boolean; product?: boolean; marketing?: boolean; code?: string }) => { + const { program: rootProgram } = await import('../index.js'); + await alertPhoneSet(phoneArg, { ...cmdOpts, json: !!rootProgram.opts().json }); + }); + + const removeCmd = phone + .command('remove') + .description('Remove your alert phone') + .option('-y, --yes', 'Skip the confirmation prompt') + .action(async (cmdOpts: { yes?: boolean }) => { + const { program: rootProgram } = await import('../index.js'); + await alertPhoneRemove({ yes: cmdOpts.yes, json: !!rootProgram.opts().json }); + }); + + const verifyCmd = phone + .command('verify ') + .description('Finish verification with the code we sent') + .action(async (code: string) => { + const { program: rootProgram } = await import('../index.js'); + await alertPhoneVerify(code, { json: !!rootProgram.opts().json }); + }); + + addExamples( + alerts, + ` +EXAMPLES: + $ hookmyapp alerts phone status + $ hookmyapp alerts phone set +14155552671 +`, + ); + + addExamples( + phone, + ` +EXAMPLES: + $ hookmyapp alerts phone status + $ hookmyapp alerts phone set +14155552671 --sms +`, + ); + + addExamples( + removeCmd, + ` +EXAMPLES: + $ hookmyapp alerts phone remove + $ hookmyapp alerts phone remove --yes +`, + ); + + addExamples( + statusCmd, + ` +EXAMPLES: + $ hookmyapp alerts phone status + $ hookmyapp alerts phone status --json +`, + ); + + addExamples( + setCmd, + ` +EXAMPLES: + $ hookmyapp alerts phone set +14155552671 + $ hookmyapp alerts phone set +14155552671 --sms +`, + ); + + addExamples( + verifyCmd, + ` +EXAMPLES: + $ hookmyapp alerts phone verify 123456 + $ hookmyapp alerts phone verify 123456 --json +`, + ); +} diff --git a/src/index.ts b/src/index.ts index 31a542d..89b1ecf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { registerWhatsappMedia } from './commands/whatsapp-media.js'; import { registerWhatsappProfile } from './commands/whatsapp-profile.js'; import { registerInstagramCommand } from './commands/instagram.js'; import { registerDoctorCommand } from './commands/doctor.js'; +import { registerAlertsCommand } from './commands/alerts.js'; import { registerBillingCommand } from './commands/billing.js'; import { registerWorkspaceCommand } from './commands/workspace.js'; import { registerCustomersCommand } from './commands/customers.js'; @@ -131,6 +132,7 @@ COMMON COMMANDS: sandbox send Send a test message via sandbox-proxy workspace list List workspaces you belong to billing View or change your plan + alerts phone Where we text you when something breaks notifications Notices from HookMyApp — problems, fixes, announcements Run "hookmyapp channels --help" for the full channel command list. @@ -185,6 +187,7 @@ registerDoctorCommand(program); // Billing registerBillingCommand(program); +registerAlertsCommand(program); // Workspace management registerWorkspaceCommand(program);