From 13590300e51797aa3f31307e0db615bd527e8144 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 10 Aug 2026 13:34:25 +0300 Subject: [PATCH 1/9] AIT-376: set and verify the alert phone from the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alert phone could be set from the web dialog or through MCP, but not from the terminal — so a CLI-first user had to open a browser or drive an agent to opt into breakage alerts. `alerts phone status | set | verify `, mirroring the MCP tools against the same user-scoped /auth/phone routes. Consents follow MCP rather than the web dialog: operational on, product and marketing behind --product / --marketing. A bare `set` must not silently consent someone to marketing. --json cannot prompt, so `set --json` reports the code is out and points at `verify`; the interactive path prompts inline. A delivery that did not report `sent` says so and stops instead of asking for a code that was never sent. --- src/commands/__tests__/alerts.test.ts | 84 +++++++++++ src/commands/alerts.ts | 210 ++++++++++++++++++++++++++ src/index.ts | 3 + 3 files changed, 297 insertions(+) create mode 100644 src/commands/__tests__/alerts.test.ts create mode 100644 src/commands/alerts.ts diff --git a/src/commands/__tests__/alerts.test.ts b/src/commands/__tests__/alerts.test.ts new file mode 100644 index 0000000..180d7bd --- /dev/null +++ b/src/commands/__tests__/alerts.test.ts @@ -0,0 +1,84 @@ +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 { 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 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: false, consentMarketing: false }); + }); + + test('When delivery fails, then it does not ask for a code', async () => { + // Arrange + vi.mocked(apiClient).mockResolvedValueOnce({ delivery: 'unavailable' }); + // Act + await alertPhoneSet('+14155552671', { json: false }); + // 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 without prompting', async () => { + // Arrange + vi.mocked(apiClient) + .mockResolvedValueOnce({ delivery: 'sent' }) + .mockResolvedValueOnce(VERIFIED); + // Act + await alertPhoneSet('+14155552671', { code: '123456', json: false }); + // Assert + expect(vi.mocked(apiClient).mock.calls[1][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(); + }); +}); diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts new file mode 100644 index 0000000..9948758 --- /dev/null +++ b/src/commands/alerts.ts @@ -0,0 +1,210 @@ +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' }, + ); +} + +/** + * Consents default to operational-only, matching the MCP tools: problem alerts + * are the reason the number exists, while product news and offers are opt-in + * because consent to marketing is not something a CLI flag should assume. + */ +export async function alertPhoneSet( + phone: string, + opts: { json?: boolean; product?: boolean; marketing?: boolean; sms?: boolean; code?: string } = {}, +): Promise { + if (!phone?.startsWith('+')) { + throw new ValidationError( + `Phone must be in international format, e.g. +14155552671 (got "${phone}").`, + 'ALERT_PHONE_FORMAT', + ); + } + + const start = (await apiClient('/auth/phone', { + method: 'POST', + body: JSON.stringify({ + phone, + consentOperational: true, + consentProduct: opts.product === true, + consentMarketing: opts.marketing === 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; + } + + // Non-interactive path: a caller who already has the code (or a script that + // will fetch it) skips the prompt entirely. + if (opts.code) { + await alertPhoneVerify(opts.code, { json: opts.json }); + 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 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('--product', 'Also receive product news') + .option('--marketing', 'Also receive offers') + .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 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( + 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 --product +`, + ); + + 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); From 29fbb30fbef77e5bdb4bfd229afa60f94326fe6d Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 10 Aug 2026 14:43:29 +0300 Subject: [PATCH 2/9] AIT-376: address Codex + CodeRabbit review on #52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate a full E.164 shape (^\+[1-9]\d{1,14}$) instead of a bare leading '+', so '+', '+abc', and over-length values are rejected before any registration request (CodeRabbit). - Refuse to start a verification when there is no TTY and neither --json nor --code is given, before the code is sent — otherwise a CI/redirected caller spends a code and quota, then blocks on a prompt that never reads (Codex P2). Interactivity is injectable for tests. --- src/commands/__tests__/alerts.test.ts | 18 ++++++++++++++++-- src/commands/alerts.ts | 19 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/commands/__tests__/alerts.test.ts b/src/commands/__tests__/alerts.test.ts index 180d7bd..5943261 100644 --- a/src/commands/__tests__/alerts.test.ts +++ b/src/commands/__tests__/alerts.test.ts @@ -45,6 +45,20 @@ describe('alerts phone', () => { 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 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' }); @@ -58,8 +72,8 @@ describe('alerts phone', () => { test('When delivery fails, then it does not ask for a code', async () => { // Arrange vi.mocked(apiClient).mockResolvedValueOnce({ delivery: 'unavailable' }); - // Act - await alertPhoneSet('+14155552671', { json: false }); + // 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'); diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index 9948758..bd1c0ef 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -56,15 +56,30 @@ export async function alertPhoneStatus(opts: { json?: boolean } = {}): Promise { - if (!phone?.startsWith('+')) { + // 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 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 && !opts.code && !interactive) { + throw new ValidationError( + 'No interactive terminal. Run with --json to start, then `alerts phone verify `, ' + + 'or pass --code .', + 'ALERT_PHONE_NO_TTY', + ); + } + const start = (await apiClient('/auth/phone', { method: 'POST', body: JSON.stringify({ From 01a3d2d47a0af5a02d832697392eb0bfb62d3dbd Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 10 Aug 2026 14:57:30 +0300 Subject: [PATCH 3/9] AIT-376: validate --code before starting the challenge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A malformed --code could only be rejected locally, but the send already went out first — burning delivery quota and superseding any live challenge. --- src/commands/__tests__/alerts.test.ts | 7 +++++++ src/commands/alerts.ts | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/commands/__tests__/alerts.test.ts b/src/commands/__tests__/alerts.test.ts index 5943261..fa9ac27 100644 --- a/src/commands/__tests__/alerts.test.ts +++ b/src/commands/__tests__/alerts.test.ts @@ -59,6 +59,13 @@ describe('alerts phone', () => { 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' }); diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index bd1c0ef..d9b3683 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -68,6 +68,14 @@ export async function alertPhoneSet( ); } + // 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 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. From 8ac54c4c782ccf9f19a6c7b2079577436e689ca7 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 10 Aug 2026 15:00:23 +0300 Subject: [PATCH 4/9] AIT-376: document the alert phone in the README and changelog --- CHANGELOG.md | 4 ++++ README.md | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a58020..f410c22 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; problem alerts on, product news and offers opt-in via `--product`/`--marketing` (AIT-376). + ## 0.14.11 — 2026-08-08 ### Added diff --git a/README.md b/README.md index 58feec7..6baf585 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. The number is yours, not a workspace setting — each person on the team sets their own. + +```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 + +# Also receive product news and offers +hookmyapp alerts phone set +14155552671 --product --marketing + +# Finish verification with the code we sent +hookmyapp alerts phone verify 123456 +``` + +We send a 6-digit code to confirm the number, and `set` asks for it. Without a terminal (CI, redirected stdin) there is no prompt, so run `set --json` and finish with `alerts phone verify `, or pass the code straight to `set --code 123456`. + +Problem alerts are on once a number is verified. Product news and offers stay off unless you pass `--product` or `--marketing`. + ## JSON output and global flags Four global flags apply to every command: From 7ba49cc62d327e42752f528460b1c2992eea42fb Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 10 Aug 2026 18:10:49 +0300 Subject: [PATCH 5/9] AIT-376: replace em dashes in alert phone changelog and readme --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f410c22..1df884e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `@gethookmyapp/cli` are documented here. ### Added -- `hookmyapp alerts phone status|set|verify` — set the phone number HookMyApp texts when something breaks. Per user, not per workspace; problem alerts on, product news and offers opt-in via `--product`/`--marketing` (AIT-376). +- `hookmyapp alerts phone status|set|verify`: set the phone number HookMyApp texts when something breaks. Per user, not per workspace; problem alerts on, product news and offers opt-in via `--product`/`--marketing` (AIT-376). ## 0.14.11 — 2026-08-08 diff --git a/README.md b/README.md index 6baf585..61a5cb4 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ hookmyapp channels move ch_xxxxxxxx "Acme Corp" # target by name or ws_ id ## Alert phone -Where we reach you if something stops working. The number is yours, not a workspace setting — each person on the team sets their own. +Where we reach you if something stops working. The number is yours, not a workspace setting. Each person on the team sets their own. ```bash # See your alert phone and what it receives From 5202c3909a83efa014b8d07b720e711d0505ed70 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 11 Aug 2026 10:11:08 +0300 Subject: [PATCH 6/9] AIT-383: verifying the alert phone consents to all alert categories Drops the --product/--marketing opt-in flags (unreleased) and the consent mechanics from README and changelog. The set call now sends all three consents true; opt-out lives in the web app. 1078 tests green. --- CHANGELOG.md | 2 +- README.md | 7 ++----- src/commands/__tests__/alerts.test.ts | 2 +- src/commands/alerts.ts | 16 +++++----------- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df884e..eabbdad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to `@gethookmyapp/cli` are documented here. ### Added -- `hookmyapp alerts phone status|set|verify`: set the phone number HookMyApp texts when something breaks. Per user, not per workspace; problem alerts on, product news and offers opt-in via `--product`/`--marketing` (AIT-376). +- `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 diff --git a/README.md b/README.md index 61a5cb4..fa0cb8c 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ hookmyapp channels move ch_xxxxxxxx "Acme Corp" # target by name or ws_ id ## Alert phone -Where we reach you if something stops working. The number is yours, not a workspace setting. Each person on the team sets their own. +Where we reach you if something stops working. ```bash # See your alert phone and what it receives @@ -235,16 +235,13 @@ hookmyapp alerts phone set +14155552671 # Get the code by SMS instead of WhatsApp hookmyapp alerts phone set +14155552671 --sms -# Also receive product news and offers -hookmyapp alerts phone set +14155552671 --product --marketing - # Finish verification with the code we sent hookmyapp alerts phone verify 123456 ``` We send a 6-digit code to confirm the number, and `set` asks for it. Without a terminal (CI, redirected stdin) there is no prompt, so run `set --json` and finish with `alerts phone verify `, or pass the code straight to `set --code 123456`. -Problem alerts are on once a number is verified. Product news and offers stay off unless you pass `--product` or `--marketing`. +Alerts are on once the number is verified. ## JSON output and global flags diff --git a/src/commands/__tests__/alerts.test.ts b/src/commands/__tests__/alerts.test.ts index fa9ac27..b45d7b5 100644 --- a/src/commands/__tests__/alerts.test.ts +++ b/src/commands/__tests__/alerts.test.ts @@ -73,7 +73,7 @@ describe('alerts phone', () => { 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: false, consentMarketing: false }); + expect(body).toMatchObject({ consentOperational: true, consentProduct: true, consentMarketing: true }); }); test('When delivery fails, then it does not ask for a code', async () => { diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index d9b3683..4b5a4a7 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -49,14 +49,10 @@ export async function alertPhoneStatus(opts: { json?: 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 @@ -93,8 +89,8 @@ export async function alertPhoneSet( body: JSON.stringify({ phone, consentOperational: true, - consentProduct: opts.product === true, - consentMarketing: opts.marketing === true, + consentProduct: true, + consentMarketing: true, channelPreference: opts.sms ? 'sms' : 'whatsapp', }), })) as { delivery?: string }; @@ -170,8 +166,6 @@ export function registerAlertsCommand(_program: Command): void { .command('set ') .description('Add or change your alert phone (international format, e.g. +14155552671)') .option('--sms', 'Deliver by SMS instead of WhatsApp') - .option('--product', 'Also receive product news') - .option('--marketing', 'Also receive offers') .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'); @@ -218,7 +212,7 @@ EXAMPLES: ` EXAMPLES: $ hookmyapp alerts phone set +14155552671 - $ hookmyapp alerts phone set +14155552671 --sms --product + $ hookmyapp alerts phone set +14155552671 --sms `, ); From 63decc077afa6e3559f39b6ccdd1841c559635ab Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 11 Aug 2026 10:49:24 +0300 Subject: [PATCH 7/9] AIT-383: print the consent disclosure before sending the verification code --- src/commands/alerts.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index 4b5a4a7..147e657 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -84,6 +84,14 @@ export async function alertPhoneSet( ); } + 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({ From d837b0e0fe86651a8b67dba68164327eb3579356 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 11 Aug 2026 11:03:42 +0300 Subject: [PATCH 8/9] AIT-383: alerts phone remove with a consequence confirm --- README.md | 3 +++ src/commands/__tests__/alerts.test.ts | 15 +++++++++++++- src/commands/alerts.ts | 30 +++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fa0cb8c..88666ab 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,9 @@ 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 ``` We send a 6-digit code to confirm the number, and `set` asks for it. Without a terminal (CI, redirected stdin) there is no prompt, so run `set --json` and finish with `alerts phone verify `, or pass the code straight to `set --code 123456`. diff --git a/src/commands/__tests__/alerts.test.ts b/src/commands/__tests__/alerts.test.ts index b45d7b5..30b1a58 100644 --- a/src/commands/__tests__/alerts.test.ts +++ b/src/commands/__tests__/alerts.test.ts @@ -6,7 +6,7 @@ vi.mock('../../api/client.js', () => ({ })); import { apiClient } from '../../api/client.js'; -import { alertPhoneSet, alertPhoneStatus, alertPhoneVerify } from '../alerts.js'; +import { alertPhoneRemove, alertPhoneSet, alertPhoneStatus, alertPhoneVerify } from '../alerts.js'; const VERIFIED = { phone: '+141•••2671', @@ -103,3 +103,16 @@ describe('alerts phone', () => { 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 index 147e657..0b3f497 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -158,6 +158,27 @@ export async function alertPhoneVerify(code: string, opts: { json?: boolean } = 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; + if (opts.json) { + output(status, { 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'); @@ -180,6 +201,15 @@ export function registerAlertsCommand(_program: Command): void { 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') From 4d3180386e849d0126ec3ffa4d22613c441f8ad8 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 11 Aug 2026 12:58:02 +0300 Subject: [PATCH 9/9] AIT-376: set --code verifies directly; JSON-safe DELETE output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A supplied code belongs to an already-sent challenge, so starting a new one superseded it and rejected the very code the caller was handed — --code now routes straight to verify with a single API call. DELETE prints null instead of undefined under --json on an empty response. README describes the failed-delivery path. --- README.md | 2 +- src/commands/__tests__/alerts.test.ts | 11 +++++----- src/commands/alerts.ts | 31 ++++++++++++++++++--------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 88666ab..cb7482f 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ hookmyapp alerts phone verify 123456 hookmyapp alerts phone remove ``` -We send a 6-digit code to confirm the number, and `set` asks for it. Without a terminal (CI, redirected stdin) there is no prompt, so run `set --json` and finish with `alerts phone verify `, or pass the code straight to `set --code 123456`. +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. diff --git a/src/commands/__tests__/alerts.test.ts b/src/commands/__tests__/alerts.test.ts index 30b1a58..4f4e735 100644 --- a/src/commands/__tests__/alerts.test.ts +++ b/src/commands/__tests__/alerts.test.ts @@ -86,15 +86,14 @@ describe('alerts phone', () => { expect(logs.join('\n')).toContain('could not deliver'); }); - test('When a code is supplied, then set verifies without prompting', async () => { + test('When a code is supplied, then set verifies directly without starting a new challenge', async () => { // Arrange - vi.mocked(apiClient) - .mockResolvedValueOnce({ delivery: 'sent' }) - .mockResolvedValueOnce(VERIFIED); + vi.mocked(apiClient).mockResolvedValueOnce(VERIFIED); // Act await alertPhoneSet('+14155552671', { code: '123456', json: false }); - // Assert - expect(vi.mocked(apiClient).mock.calls[1][0]).toBe('/auth/phone/verify'); + // 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 () => { diff --git a/src/commands/alerts.ts b/src/commands/alerts.ts index 0b3f497..0fb28c3 100644 --- a/src/commands/alerts.ts +++ b/src/commands/alerts.ts @@ -72,11 +72,19 @@ export async function alertPhoneSet( 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 && !opts.code && !interactive) { + if (!opts.json && !interactive) { throw new ValidationError( 'No interactive terminal. Run with --json to start, then `alerts phone verify `, ' + 'or pass --code .', @@ -118,13 +126,6 @@ export async function alertPhoneSet( return; } - // Non-interactive path: a caller who already has the code (or a script that - // will fetch it) skips the prompt entirely. - if (opts.code) { - await alertPhoneVerify(opts.code, { json: opts.json }); - return; - } - if (opts.json) { // A prompt cannot run under --json. Report that the code is out and let // the caller finish with `verify`. @@ -171,9 +172,10 @@ export async function alertPhoneRemove(opts: { json?: boolean; yes?: boolean } = return; } } - const status = (await apiClient('/auth/phone', { method: 'DELETE' })) as AlertPhoneStatus; + const status = (await apiClient('/auth/phone', { method: 'DELETE' })) as AlertPhoneStatus | undefined; if (opts.json) { - output(status, { json: true }); + // A 204 leaves status undefined; print valid JSON either way. + output(status ?? null, { json: true }); return; } console.log(c.success('Alert phone removed.')); @@ -236,6 +238,15 @@ EXAMPLES: `, ); + addExamples( + removeCmd, + ` +EXAMPLES: + $ hookmyapp alerts phone remove + $ hookmyapp alerts phone remove --yes +`, + ); + addExamples( statusCmd, `