Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/commands/pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,14 @@ async function proxyRefusal(res: Response): Promise<ApiError> {
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<string, unknown>;
if (typeof oai.message === 'string') message = oai.message;
Expand Down
24 changes: 16 additions & 8 deletions src/commands/phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,13 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise<void> {
if (flags.number !== undefined && flags.areaCode !== undefined) {
throw new UsageError('Pass --number <e164> (exact, from search) OR --area-code <c>, 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 <c> (3-digit US area code, e.g. 415), or --number <e164> 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\`).`,
Expand All @@ -260,8 +267,7 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise<void> {
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`,
Expand All @@ -279,8 +285,8 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise<void> {
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 <numberId> (ids: \`floe phone list --json\`).`,
Expand All @@ -292,7 +298,7 @@ export async function phoneBuyCommand(flags: PhoneFlags): Promise<void> {
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,
);
Expand Down Expand Up @@ -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 <c>] [--agent <name|id>]
floe phone buy [--number <e164> | --area-code <c>] [--agent <name|id>] [--yes]
floe phone buy (--area-code <c> | --number <e164>) [--agent <name|id>] [--yes]
floe phone list [--all] [--agent <name|id>]
floe phone release <numberId> [--agent <name|id>] [--yes]
floe phone calls <numberId> [--limit <n>] [--agent <name|id>]
Expand All @@ -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 <c> (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
Expand Down
11 changes: 9 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiError> {
let message = `HTTP ${res.status}`;
let code: string | undefined;
Expand All @@ -47,7 +49,12 @@ async function toApiError(res: Response): Promise<ApiError> {
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<string, unknown>;
if (typeof oai.message === 'string') message = oai.message;
Expand Down
26 changes: 26 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions test/phone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Loading