diff --git a/src/api/client.test.ts b/src/api/client.test.ts index e199424..ae5e0b7 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -33,6 +33,31 @@ describe('createApiClient', () => { global.fetch = originalFetch }) + test('tolerates an empty 201 body without throwing a JSON parse error', async () => { + // Option-creation endpoints return 201 with no body and no Content-Length:0, + // which makes openapi-fetch's default JSON parser throw. The client should + // normalize this to undefined data instead. + global.fetch = (async () => new Response(null, { status: 201 })) as unknown as typeof fetch + + const client = createApiClient({ + baseUrl: 'https://api.example.invalid', + apiKey: 'mna_live_test', + }) + + const { data, error } = await client.POST( + '/v1/trips/{id}/variants/{variantId}/destinations/{destinationKey}/options/accommodation', + { + params: { path: { id: 't', variantId: 'v', destinationKey: 'd' } }, + body: {} as never, + }, + ) + + expect(error).toBeUndefined() + expect(data).toBeUndefined() + + global.fetch = originalFetch + }) + test('throws ApiError with status + body on non-2xx', async () => { global.fetch = (async () => { return new Response(JSON.stringify({ message: 'API key is missing' }), { diff --git a/src/api/client.ts b/src/api/client.ts index df7acbf..c6592d4 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -34,6 +34,21 @@ export function createApiClient({ baseUrl, apiKey }: CreateApiClientOptions): Ap (body as { message?: string } | undefined)?.message ?? `HTTP ${response.status}` throw new ApiError(response.status, message, body) } + + // Some endpoints (e.g. option creation) return a 2xx with an empty body and + // no `Content-Length: 0`, which makes openapi-fetch's JSON parser throw + // "Failed to parse JSON". Normalize empty success bodies so the parser + // yields `data: undefined` instead of crashing the command. + const text = await response.clone().text() + if (text.trim() === '') { + const headers = new Headers(response.headers) + headers.set('Content-Length', '0') + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers, + }) + } return response }, } diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index ae5068b..68fd210 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -2,7 +2,9 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { randomBytes } from 'node:crypto' import { hostname } from 'node:os' import { spawn } from 'node:child_process' +import { createApiClient } from '../api/client' import { saveCredentials, type Credentials } from './credentials-store' +import { fetchIdentity } from './fetch-identity' const TIMEOUT_MS = 5 * 60 * 1000 const PORT_MIN = 1024 @@ -57,7 +59,7 @@ export async function browserLogin(input: BrowserLoginInput): Promise { + try { + return await fetchIdentity(createApiClient({ baseUrl: apiBaseUrl, apiKey })) + } catch { + return { id: 'unknown', email: 'unknown@local', name: 'CLI user' } + } +} + interface CallbackListener { port: number gotCallback: Promise<{ apiKey: string }> @@ -80,6 +95,9 @@ async function listenForCallback(expectedState: string): Promise { + // Without this the browser holds a keep-alive socket open, which keeps + // the event loop alive and hangs the CLI after a successful login. + res.setHeader('Connection', 'close') try { if (!req.url) throw new Error('Empty request URL') const url = new URL(req.url, 'http://127.0.0.1') @@ -108,6 +126,7 @@ async function listenForCallback(expectedState: string): Promise { + const { data, error } = await client.GET('/v1/me') + if (error) throw new Error(`Failed to fetch identity: ${JSON.stringify(error)}`) + if (!data) throw new Error('No identity returned by /v1/me.') + + return { + id: data.userId, + email: data.email ?? 'unknown@local', + name: data.name ?? 'CLI user', + } +} diff --git a/src/auth/paste-token-login.ts b/src/auth/paste-token-login.ts index 5370ff6..b887678 100644 --- a/src/auth/paste-token-login.ts +++ b/src/auth/paste-token-login.ts @@ -1,5 +1,6 @@ import { createApiClient } from '../api/client' import { saveCredentials, type Credentials } from './credentials-store' +import { fetchIdentity } from './fetch-identity' export interface PasteTokenLoginInput { apiKey: string @@ -7,27 +8,17 @@ export interface PasteTokenLoginInput { } /** - * Validates a pasted API key by hitting GET /v1/trips (the only authenticated - * read endpoint available today). On success, persists credentials with a - * placeholder user (real user info will be filled in by `mna whoami --verify` - * once Phase 1 ships GET /v1/me). + * Validates a pasted API key by fetching the key holder's identity from + * GET /v1/me, then persists credentials with the real user info. If the key is + * invalid, ApiError(401) bubbles up. */ export async function pasteTokenLogin(input: PasteTokenLoginInput): Promise { const client = createApiClient({ baseUrl: input.apiBaseUrl, apiKey: input.apiKey }) - // Sanity-check the key. If it's invalid, ApiError(401) bubbles up. - // The /v1/trips endpoint requires query params per the OpenAPI schema; - // pass empty values to satisfy the type-checker. - await client.GET('/v1/trips', { params: { query: { includeExample: false } as never } }) - const creds: Credentials = { version: 1, apiKey: input.apiKey, - user: { - id: 'unknown', - email: 'unknown@local', - name: 'CLI user', - }, + user: await fetchIdentity(client), apiBaseUrl: input.apiBaseUrl, createdAt: new Date().toISOString(), } diff --git a/src/commands/collections/edit.ts b/src/commands/collections/edit.ts index 551f5ed..6d8a621 100644 --- a/src/commands/collections/edit.ts +++ b/src/commands/collections/edit.ts @@ -4,20 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' - -async function readJsonBody(path: string): Promise> { - const file = Bun.file(path) - if (!(await file.exists())) { - throw new Error(`JSON file not found: ${path}`) - } - const parsed = (await file.json()) as unknown - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error( - `Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`, - ) - } - return parsed as Record -} +import { readJsonObject } from '../../util/json-file' export const collectionsEditCommand = defineCommand({ meta: { name: 'edit', description: 'Update fields on a collection from a JSON file.' }, @@ -32,7 +19,7 @@ export const collectionsEditCommand = defineCommand({ }, async run({ args }) { try { - const body = await readJsonBody(args['from-json']) + const body = await readJsonObject(args['from-json']) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/events/add.ts b/src/commands/events/add.ts index e7b8b14..b9db2f4 100644 --- a/src/commands/events/add.ts +++ b/src/commands/events/add.ts @@ -4,18 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' - -async function readJsonBody(path: string): Promise> { - const file = Bun.file(path) - if (!(await file.exists())) { - throw new Error(`JSON file not found: ${path}`) - } - const parsed = (await file.json()) as unknown - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`) - } - return parsed as Record -} +import { readJsonObject } from '../../util/json-file' export const eventsAddCommand = defineCommand({ meta: { name: 'add', description: 'Add an event option to a variant from a JSON file.' }, @@ -31,7 +20,7 @@ export const eventsAddCommand = defineCommand({ }, async run({ args }) { try { - const body = await readJsonBody(args['from-json']) + const body = await readJsonObject(args['from-json']) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/events/edit.ts b/src/commands/events/edit.ts index 42ba0f3..e5422c3 100644 --- a/src/commands/events/edit.ts +++ b/src/commands/events/edit.ts @@ -4,18 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' - -async function readJsonBody(path: string): Promise> { - const file = Bun.file(path) - if (!(await file.exists())) { - throw new Error(`JSON file not found: ${path}`) - } - const parsed = (await file.json()) as unknown - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`) - } - return parsed as Record -} +import { readJsonObject } from '../../util/json-file' export const eventsEditCommand = defineCommand({ meta: { name: 'edit', description: 'Update fields on an event from a JSON file.' }, @@ -32,7 +21,7 @@ export const eventsEditCommand = defineCommand({ }, async run({ args }) { try { - const body = await readJsonBody(args['from-json']) + const body = await readJsonObject(args['from-json']) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/goals/add.ts b/src/commands/goals/add.ts index 0dcd421..c2d1634 100644 --- a/src/commands/goals/add.ts +++ b/src/commands/goals/add.ts @@ -4,20 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' - -async function readJsonBody(path: string): Promise> { - const file = Bun.file(path) - if (!(await file.exists())) { - throw new Error(`JSON file not found: ${path}`) - } - const parsed = (await file.json()) as unknown - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error( - `Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`, - ) - } - return parsed as Record -} +import { readJsonObject } from '../../util/json-file' export const goalsAddCommand = defineCommand({ meta: { name: 'add', description: 'Create a new travel goal from a JSON file.' }, @@ -31,7 +18,7 @@ export const goalsAddCommand = defineCommand({ }, async run({ args }) { try { - const body = await readJsonBody(args['from-json']) + const body = await readJsonObject(args['from-json']) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/goals/edit.ts b/src/commands/goals/edit.ts index ef6ed40..6b9f94f 100644 --- a/src/commands/goals/edit.ts +++ b/src/commands/goals/edit.ts @@ -4,20 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' - -async function readJsonBody(path: string): Promise> { - const file = Bun.file(path) - if (!(await file.exists())) { - throw new Error(`JSON file not found: ${path}`) - } - const parsed = (await file.json()) as unknown - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error( - `Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`, - ) - } - return parsed as Record -} +import { readJsonObject } from '../../util/json-file' export const goalsEditCommand = defineCommand({ meta: { name: 'edit', description: 'Update fields on a goal from a JSON file.' }, @@ -32,7 +19,7 @@ export const goalsEditCommand = defineCommand({ }, async run({ args }) { try { - const body = await readJsonBody(args['from-json']) + const body = await readJsonObject(args['from-json']) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/options/add.ts b/src/commands/options/add.ts index 7f413a7..686988f 100644 --- a/src/commands/options/add.ts +++ b/src/commands/options/add.ts @@ -4,6 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' +import { readJsonObject } from '../../util/json-file' const KINDS = ['accommodation', 'transport', 'getting-around'] as const type Kind = (typeof KINDS)[number] @@ -14,18 +15,6 @@ function assertKind(value: string): asserts value is Kind { } } -async function readJsonBody(path: string): Promise> { - const file = Bun.file(path) - if (!(await file.exists())) { - throw new Error(`JSON file not found: ${path}`) - } - const parsed = (await file.json()) as unknown - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`) - } - return parsed as Record -} - export const optionsAddCommand = defineCommand({ meta: { name: 'add', @@ -50,7 +39,7 @@ export const optionsAddCommand = defineCommand({ async run({ args }) { try { assertKind(args.kind) - const body = await readJsonBody(args['from-json']) + const body = await readJsonObject(args['from-json']) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/commands/options/edit.ts b/src/commands/options/edit.ts index bab2e6f..ad727e9 100644 --- a/src/commands/options/edit.ts +++ b/src/commands/options/edit.ts @@ -4,6 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' +import { readJsonObject } from '../../util/json-file' const KINDS = ['accommodation', 'transport', 'getting-around'] as const type Kind = (typeof KINDS)[number] @@ -14,18 +15,6 @@ function assertKind(value: string): asserts value is Kind { } } -async function readJsonBody(path: string): Promise> { - const file = Bun.file(path) - if (!(await file.exists())) { - throw new Error(`JSON file not found: ${path}`) - } - const parsed = (await file.json()) as unknown - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`) - } - return parsed as Record -} - export const optionsEditCommand = defineCommand({ meta: { name: 'edit', @@ -51,7 +40,7 @@ export const optionsEditCommand = defineCommand({ async run({ args }) { try { assertKind(args.kind) - const body = await readJsonBody(args['from-json']) + const body = await readJsonObject(args['from-json']) const creds = await loadCredentials() const apiKey = resolveApiKey(creds) diff --git a/src/util/json-file.test.ts b/src/util/json-file.test.ts new file mode 100644 index 0000000..1b22405 --- /dev/null +++ b/src/util/json-file.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readJsonObject } from './json-file' + +let dir: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'mna-json-test-')) +}) + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +describe('readJsonObject', () => { + test('reads and parses a JSON object via node:fs (no Bun global needed)', async () => { + const path = join(dir, 'body.json') + await writeFile(path, JSON.stringify({ name: 'Hotel', nights: 3 })) + expect(await readJsonObject(path)).toEqual({ name: 'Hotel', nights: 3 }) + }) + + test('throws a clear error when the file is missing', async () => { + await expect(readJsonObject(join(dir, 'nope.json'))).rejects.toThrow(/JSON file not found/) + }) + + test('throws on invalid JSON', async () => { + const path = join(dir, 'bad.json') + await writeFile(path, '{ not json') + await expect(readJsonObject(path)).rejects.toThrow(/Invalid JSON/) + }) + + test('rejects arrays and non-objects', async () => { + const path = join(dir, 'arr.json') + await writeFile(path, '[1, 2, 3]') + await expect(readJsonObject(path)).rejects.toThrow(/Expected JSON object.*got array/) + }) +}) diff --git a/src/util/json-file.ts b/src/util/json-file.ts new file mode 100644 index 0000000..6fa4e8b --- /dev/null +++ b/src/util/json-file.ts @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises' + +/** + * Read a file and parse it as a JSON object. Uses node:fs so it works in the + * compiled binary (which runs on Node, where the `Bun` global is undefined). + */ +export async function readJsonObject(path: string): Promise> { + let raw: string + try { + raw = await readFile(path, 'utf8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`JSON file not found: ${path}`) + } + throw err + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + throw new Error(`Invalid JSON in ${path}: ${(err as Error).message}`) + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error( + `Expected JSON object in ${path}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}.`, + ) + } + return parsed as Record +}