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
25 changes: 25 additions & 0 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }), {
Expand Down
15 changes: 15 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down
21 changes: 20 additions & 1 deletion src/auth/browser-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,14 +59,27 @@ export async function browserLogin(input: BrowserLoginInput): Promise<BrowserLog
const credentials: Credentials = {
version: 1,
apiKey,
user: { id: 'unknown', email: 'unknown@local', name: 'CLI user' },
user: await resolveUser(input.apiBaseUrl, apiKey),
apiBaseUrl: input.apiBaseUrl,
createdAt: new Date().toISOString(),
}
await saveCredentials(credentials)
return { credentials, consentUrl }
}

/**
* The browser flow already proved the key is valid server-side, so a failure to
* fetch identity (transient network blip) shouldn't fail the whole login — fall
* back to a placeholder that `mna whoami --verify` can refresh later.
*/
async function resolveUser(apiBaseUrl: string, apiKey: string): Promise<Credentials['user']> {
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 }>
Expand All @@ -80,6 +95,9 @@ async function listenForCallback(expectedState: string): Promise<CallbackListene
})

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
// 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')
Expand Down Expand Up @@ -108,6 +126,7 @@ async function listenForCallback(expectedState: string): Promise<CallbackListene
resolveCallback({ apiKey: key })
} finally {
server.close()
server.closeAllConnections?.()
}
})

Expand Down
19 changes: 19 additions & 0 deletions src/auth/fetch-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Api } from '../api/client'
import type { Credentials } from './credentials-store'

/**
* Fetch the identity of the API key holder from GET /v1/me and map it into the
* stored credentials shape. Doubles as a key-validity check: an invalid key
* makes the client throw an ApiError(401).
*/
export async function fetchIdentity(client: Api): Promise<Credentials['user']> {
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',
}
}
19 changes: 5 additions & 14 deletions src/auth/paste-token-login.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,24 @@
import { createApiClient } from '../api/client'
import { saveCredentials, type Credentials } from './credentials-store'
import { fetchIdentity } from './fetch-identity'

export interface PasteTokenLoginInput {
apiKey: string
apiBaseUrl: string
}

/**
* 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<Credentials> {
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(),
}
Expand Down
17 changes: 2 additions & 15 deletions src/commands/collections/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
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<string, unknown>
}
import { readJsonObject } from '../../util/json-file'

export const collectionsEditCommand = defineCommand({
meta: { name: 'edit', description: 'Update fields on a collection from a JSON file.' },
Expand All @@ -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)
Expand Down
15 changes: 2 additions & 13 deletions src/commands/events/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
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<string, unknown>
}
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.' },
Expand All @@ -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)
Expand Down
15 changes: 2 additions & 13 deletions src/commands/events/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
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<string, unknown>
}
import { readJsonObject } from '../../util/json-file'

export const eventsEditCommand = defineCommand({
meta: { name: 'edit', description: 'Update fields on an event from a JSON file.' },
Expand All @@ -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)
Expand Down
17 changes: 2 additions & 15 deletions src/commands/goals/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
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<string, unknown>
}
import { readJsonObject } from '../../util/json-file'

export const goalsAddCommand = defineCommand({
meta: { name: 'add', description: 'Create a new travel goal from a JSON file.' },
Expand All @@ -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)
Expand Down
17 changes: 2 additions & 15 deletions src/commands/goals/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
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<string, unknown>
}
import { readJsonObject } from '../../util/json-file'

export const goalsEditCommand = defineCommand({
meta: { name: 'edit', description: 'Update fields on a goal from a JSON file.' },
Expand All @@ -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)
Expand Down
15 changes: 2 additions & 13 deletions src/commands/options/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -14,18 +15,6 @@ function assertKind(value: string): asserts value is Kind {
}
}

async function readJsonBody(path: string): Promise<Record<string, unknown>> {
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<string, unknown>
}

export const optionsAddCommand = defineCommand({
meta: {
name: 'add',
Expand All @@ -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)
Expand Down
15 changes: 2 additions & 13 deletions src/commands/options/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -14,18 +15,6 @@ function assertKind(value: string): asserts value is Kind {
}
}

async function readJsonBody(path: string): Promise<Record<string, unknown>> {
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<string, unknown>
}

export const optionsEditCommand = defineCommand({
meta: {
name: 'edit',
Expand All @@ -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)
Expand Down
Loading
Loading