diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 0709d27..fab7f05 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -127,7 +127,14 @@ async function proxyRefusal(res: Response): Promise { const err = body.error; if (typeof err === 'string') { code = err; - message = typeof body.message === 'string' ? body.message : err; + // `message` or `detail` carry the human sentence for the code + // (same precedence as FloeApi's toApiError — keep them in step). + message = + typeof body.message === 'string' + ? body.message + : typeof body.detail === 'string' + ? body.detail + : err; } else if (err && typeof err === 'object') { const oai = err as Record; if (typeof oai.message === 'string') message = oai.message; diff --git a/src/commands/phone.ts b/src/commands/phone.ts index 0df9fc3..8221dd7 100644 --- a/src/commands/phone.ts +++ b/src/commands/phone.ts @@ -250,6 +250,13 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise { if (flags.number !== undefined && flags.areaCode !== undefined) { throw new UsageError('Pass --number (exact, from search) OR --area-code , not both.'); } + // The carrier picks a number by area code or exact E.164 — there is no + // "any US number" purchase (the API refuses with 400 area_code_required). + if (flags.number === undefined && flags.areaCode === undefined) { + throw new UsageError( + 'An area code is required: pass --area-code (3-digit US area code, e.g. 415), or --number from `floe phone search`.', + ); + } if (flags.number !== undefined && !US_E164.test(flags.number)) { throw new UsageError( `Invalid --number "${flags.number}" — US E.164, e.g. +14155550123 (find one with \`floe phone search\`).`, @@ -260,8 +267,7 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise { const ctx = await devContext(flags); const agent = await targetAgent(ctx, flags.agent); const agentName = agent.name ?? agent.id; - const what = - flags.number ?? (flags.areaCode ? `a number in area code ${flags.areaCode}` : 'a US phone number'); + const what = flags.number ?? `a number in area code ${flags.areaCode}`; // Money moves at purchase: the first month's rental is debited immediately. await confirmAction( `buy ${what} for agent "${agentName}" — the first month's rental debits the agent balance now`, @@ -279,8 +285,8 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise { ctx.api.devRaw('POST', `/v1/developer/agents/${agent.id}/numbers`, body), ); } catch (err) { - // The phone routes carry their explanation in a `detail` field the shared - // error mapper doesn't read — remap the common codes to real sentences. + // The shared error mapper already surfaces the phone routes' `detail` + // sentence; the common codes get CLI-specific next steps on top. if (err instanceof ApiError && err.code === 'number_exists') { throw new ApiError( `Agent "${agentName}" already has a live phone number — one per agent. Release it first: floe phone release (ids: \`floe phone list --json\`).`, @@ -292,7 +298,7 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise { throw new ApiError( flags.number ? `${flags.number} is no longer available — run \`floe phone search\` again.` - : `No US numbers available${flags.areaCode ? ` in area code ${flags.areaCode}` : ''} — try another area code.`, + : `No US numbers available in area code ${flags.areaCode} — try another area code.`, err.status, err.code, ); @@ -645,7 +651,7 @@ export const phoneDef: CommandDef = { name: 'phone', summary: 'search | buy | list | release | calls | usage | voice | test-call — Floe Phone', usage: `Usage: floe phone search [--area-code ] [--agent ] - floe phone buy [--number | --area-code ] [--agent ] [--yes] + floe phone buy (--area-code | --number ) [--agent ] [--yes] floe phone list [--all] [--agent ] floe phone release [--agent ] [--yes] floe phone calls [--limit ] [--agent ] @@ -659,8 +665,10 @@ Floe Phone: give an agent a real US phone number, metered on the same ledger. Default agent: the one this machine uses (switch with \`floe use\`). search Preview purchasable US local numbers (free); buy an exact one with buy --number - buy Buy a number and bind it to the agent — the FIRST MONTH'S RENTAL - debits the agent balance immediately (confirms; --yes to skip) + buy Buy a number in --area-code (required; or an exact + --number from search) and bind it to the agent — the FIRST + MONTH'S RENTAL debits the agent balance immediately + (confirms; --yes to skip) list The agent's numbers, history included; --all shows every number across your fleet with 7-day calls and month-to-date spend release Release a number permanently — IRREVERSIBLE; type the number diff --git a/src/lib/api.ts b/src/lib/api.ts index ab22416..186cc8b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -37,7 +37,9 @@ interface RequestOptions { timeoutMs?: number; } -/** Developer-surface errors: {error, message?, details?, next?:{hint}}. Gateway: {error:{message,type,code}}. */ +/** Developer-surface errors: {error, message?|detail?, details?, next?:{hint}}. Gateway: {error:{message,type,code}}. + * `message` (auth/accounts) and `detail` (phone, voice, calls) are both the + * human sentence for the `error` code — prefer either over the bare code. */ async function toApiError(res: Response): Promise { let message = `HTTP ${res.status}`; let code: string | undefined; @@ -47,7 +49,12 @@ async function toApiError(res: Response): Promise { const err = body.error; if (typeof err === 'string') { code = err; - message = typeof body.message === 'string' ? body.message : err; + message = + typeof body.message === 'string' + ? body.message + : typeof body.detail === 'string' + ? body.detail + : err; } else if (err && typeof err === 'object') { const oai = err as Record; if (typeof oai.message === 'string') message = oai.message; diff --git a/test/api.test.ts b/test/api.test.ts index eb1ff72..54318fb 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -73,6 +73,32 @@ describe('ApiError mapping', () => { expect(err.exitCode).toBe(5); }); + it('uses the body `detail` sentence as the message when there is no `message`', async () => { + vi.stubGlobal('fetch', async () => + jsonResponse(400, { + error: 'area_code_required', + detail: 'Enter a 3-digit US area code (e.g. 415) to pick a number', + }), + ); + const err = (await new FloeApi('https://api.example', 'floe_live_x') + .devRaw('POST', '/v1/developer/agents/1/numbers', {}) + .catch((e: unknown) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.code).toBe('area_code_required'); + expect(err.message).toBe('Enter a 3-digit US area code (e.g. 415) to pick a number'); + }); + + it('prefers `message` over `detail` when a body carries both', async () => { + vi.stubGlobal('fetch', async () => + jsonResponse(400, { error: 'x_code', message: 'M wins', detail: 'D loses' }), + ); + const err = (await new FloeApi('https://api.example', 'floe_live_x') + .devRaw('POST', '/v1/developer/agents/1/numbers', {}) + .catch((e: unknown) => e)) as ApiError; + expect(err.code).toBe('x_code'); + expect(err.message).toBe('M wins'); + }); + it('maps 401/403 to exit 4 and other statuses to 1', () => { expect(new ApiError('x', 401).exitCode).toBe(4); expect(new ApiError('x', 403).exitCode).toBe(4); diff --git a/test/phone.test.ts b/test/phone.test.ts index d1b8a36..9b90cb2 100644 --- a/test/phone.test.ts +++ b/test/phone.test.ts @@ -205,6 +205,25 @@ describe('phone buy', () => { expect(spy).not.toHaveBeenCalled(); }); + it('requires --area-code or --number before any network call', async () => { + const spy = stubNoFetch(); + await main(['phone', 'buy', '--yes']); + expect(stderr).toContain('area code is required'); + expect(stderr).toContain('--area-code'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + + it('surfaces the API detail when the server refuses a missing area code', async () => { + stubFetch(400, { + error: 'area_code_required', + detail: 'Enter a 3-digit US area code (e.g. 415) to pick a number, or pass an exact phoneNumber from a search', + }); + await main(['phone', 'buy', '--area-code', '415', '--yes']); + expect(stderr).toContain('area code'); + expect(process.exitCode).toBe(1); + }); + it('rejects a malformed --number before any network call', async () => { const spy = stubNoFetch(); await main(['phone', 'buy', '--number', '415-555-0100', '--yes']);