From a5a53861aa4196ff5cefc1f9e6def7d48ae32c3e Mon Sep 17 00:00:00 2001 From: Nathan Clevenger <4130910+nathanclevenger@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:04:39 -0500 Subject: [PATCH 1/3] fix(core): surface nested auth-middleware error messages, not [object Object] The auth middleware returns { status, error: { message } } while every other endpoint returns { error: string }. The client assumed the flat shape, so any 401 (invalid, expired, or revoked credential) rendered as '[object Object]' in the CLI, SDK, and MCP tools, hiding the server's actual explanation. Co-Authored-By: Claude Fable 5 --- src/core/client.ts | 9 ++++-- test/core/client.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/core/client.ts b/src/core/client.ts index 79dfc66..f70cb56 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -61,10 +61,15 @@ export class AutoDevClient { if (!response.ok) { const body = await response.json().catch(() => ({ error: response.statusText })) - const errorBody = body as { error?: string; suggestion?: string; upgradeLink?: string } + // Most endpoints return { error: string }; the auth middleware returns { error: { message } } + const errorBody = body as { error?: string | { message?: string }; suggestion?: string; upgradeLink?: string } + const message = + typeof errorBody.error === 'string' + ? errorBody.error + : (errorBody.error?.message ?? response.statusText) const code = this.statusToCode(response.status) const suggestion = this.getSuggestion(response.status, definition.tier, errorBody.upgradeLink) - throw new AutoDevError(response.status, code, errorBody.error ?? response.statusText, suggestion) + throw new AutoDevError(response.status, code, message, suggestion) } const rawData = (await response.json()) as T diff --git a/test/core/client.test.ts b/test/core/client.test.ts index 1c6860d..90102f2 100644 --- a/test/core/client.test.ts +++ b/test/core/client.test.ts @@ -160,6 +160,66 @@ describe('AutoDevClient', () => { fetchSpy.mockRestore() }) + it('surfaces flat string error bodies as the error message', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'Growth plan required' }), { + status: 403, + headers: { 'content-type': 'application/json', 'x-request-id': 'req_err_flat' }, + }), + ) + + await expect(client.request('specs', { vin: '1HGCM82633A004352' })).rejects.toMatchObject({ + name: 'AutoDevError', + status: 403, + message: 'Growth plan required', + }) + + fetchSpy.mockRestore() + }) + + it('surfaces nested auth-middleware error bodies instead of [object Object]', async () => { + // The auth middleware returns { status, error: { message } } — not the flat { error: string } shape + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + status: 401, + error: { message: 'Authentication required. Get your API key at https://auto.dev/dashboard/api-keys' }, + }), + { + status: 401, + headers: { 'content-type': 'application/json', 'x-request-id': 'req_err_nested' }, + }, + ), + ) + + await expect(client.request('decode', { vin: '1HGCM82633A004352' })).rejects.toMatchObject({ + name: 'AutoDevError', + status: 401, + code: 'UNAUTHORIZED', + message: 'Authentication required. Get your API key at https://auto.dev/dashboard/api-keys', + }) + + fetchSpy.mockRestore() + }) + + it('falls back to statusText when the error body has no message', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ status: 401, error: {} }), { + status: 401, + statusText: 'Unauthorized', + headers: { 'content-type': 'application/json', 'x-request-id': 'req_err_empty' }, + }), + ) + + await expect(client.request('decode', { vin: '1HGCM82633A004352' })).rejects.toMatchObject({ + name: 'AutoDevError', + status: 401, + message: 'Unauthorized', + }) + + fetchSpy.mockRestore() + }) + it('builds plate URL with state and number path params', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( new Response(JSON.stringify({ vin: '1HGCM82633A004352' }), { From 493bff9939bbfcc9acb4128d2b3453126b1d47ec Mon Sep 17 00:00:00 2001 From: Nathan Clevenger <4130910+nathanclevenger@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:04:39 -0500 Subject: [PATCH 2/3] fix(cli): formatted error instead of a stack trace when no credentials exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveAuth threw a plain Error, and the command factory only formats AutoDevError — so a brand-new user's first command crashed with a raw Node stack trace. resolveAuth now throws AutoDevError(401) with the login hint as the suggestion, and the factory formats unexpected errors as a fallback. Co-Authored-By: Claude Fable 5 --- src/auth/resolve.ts | 3 ++- src/cli/factory.ts | 3 ++- test/auth/resolve.test.ts | 31 +++++++++++++++++++++++++++++++ test/cli/factory-wiring.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/auth/resolve.ts b/src/auth/resolve.ts index a523755..53c348e 100644 --- a/src/auth/resolve.ts +++ b/src/auth/resolve.ts @@ -1,4 +1,5 @@ import { getValidToken } from './oauth' +import { AutoDevError } from '../errors' import type { ClientType } from '../core/types' export async function resolveAuth(options?: { apiKey?: string; clientType?: ClientType }): Promise { @@ -8,7 +9,7 @@ export async function resolveAuth(options?: { apiKey?: string; clientType?: Clie await getValidToken(options?.clientType ?? 'cli') if (!apiKey) { - throw new Error('No API key found. Set AUTODEV_API_KEY or run: auto login') + throw new AutoDevError(401, 'UNAUTHORIZED', 'No API key found', 'Set AUTODEV_API_KEY or run: auto login') } return apiKey as string diff --git a/src/cli/factory.ts b/src/cli/factory.ts index 06c25dd..6449420 100644 --- a/src/cli/factory.ts +++ b/src/cli/factory.ts @@ -119,7 +119,8 @@ export function makeCommand(def: CommandDef): Command { } process.exit(1) } - throw err + console.error(formatError(err instanceof Error ? err.message : String(err))) + process.exit(1) } }) diff --git a/test/auth/resolve.test.ts b/test/auth/resolve.test.ts index dc33fef..14a6f71 100644 --- a/test/auth/resolve.test.ts +++ b/test/auth/resolve.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +// Stub the stored-credential lookup so tests never read a real ~/.id.org.ai token +let storedToken: string | null = null + +vi.mock('../../src/auth/oauth', () => ({ + getValidToken: async () => storedToken, +})) + describe('resolveAuth', () => { let originalApiKey: string | undefined @@ -28,4 +35,28 @@ describe('resolveAuth', () => { const key = await resolveAuth({}) expect(key).toBe('sk_env') }) + + it('falls back to the stored login token', async () => { + delete process.env.AUTODEV_API_KEY + storedToken = 'stored_oauth_token' + const { resolveAuth } = await import('../../src/auth/resolve') + const key = await resolveAuth({}) + expect(key).toBe('stored_oauth_token') + storedToken = null + }) + + it('throws a typed AutoDevError when no credentials exist anywhere', async () => { + delete process.env.AUTODEV_API_KEY + storedToken = null + const { resolveAuth } = await import('../../src/auth/resolve') + const { AutoDevError } = await import('../../src/errors') + const err = await resolveAuth({}).catch((e: unknown) => e) + expect(err).toBeInstanceOf(AutoDevError) + expect(err).toMatchObject({ + status: 401, + code: 'UNAUTHORIZED', + message: 'No API key found', + suggestion: 'Set AUTODEV_API_KEY or run: auto login', + }) + }) }) diff --git a/test/cli/factory-wiring.test.ts b/test/cli/factory-wiring.test.ts index 2a468d3..dbbc453 100644 --- a/test/cli/factory-wiring.test.ts +++ b/test/cli/factory-wiring.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const ctorCalls: Array> = [] +let requestImpl: (() => Promise) | null = null vi.mock('../../src/core/client', () => { class MockAutoDevClient { @@ -8,6 +9,7 @@ vi.mock('../../src/core/client', () => { ctorCalls.push(opts) } async request() { + if (requestImpl) return requestImpl() return { data: {}, meta: { requestId: '', tier: 'starter' } } } } @@ -39,4 +41,27 @@ describe('CLI makeCommand wiring', () => { logSpy.mockRestore() errSpy.mockRestore() }) + + it('formats unexpected errors instead of rethrowing a raw stack trace', async () => { + requestImpl = async () => { + throw new Error('boom from below') + } + const cmd = makeCommand({ name: 'decode', description: 'Decode VIN' }) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('__exit__') + }) as never) + + await expect(cmd.parseAsync(['node', 'decode', '1HGCM82633A004352', '--json'])).rejects.toThrow('__exit__') + + expect(exitSpy).toHaveBeenCalledWith(1) + const printed = errSpy.mock.calls.flat().join('\n') + expect(printed).toContain('boom from below') + + logSpy.mockRestore() + errSpy.mockRestore() + exitSpy.mockRestore() + requestImpl = null + }) }) From 61ef835fd12715b6cb5f5a98422b1c711c9b6a46 Mon Sep 17 00:00:00 2001 From: Nathan Clevenger <4130910+nathanclevenger@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:04:39 -0500 Subject: [PATCH 3/3] fix(test): sandbox HOME so the suite can't wipe or revoke real credentials The clearCredentials test called the real implementation against the module-level default storage: it POSTed a live token revocation to id.org.ai with the developer's stored access token, then deleted ~/.id.org.ai/token and ~/.oauth.do/token. Running 'pnpm test' logged the developer out of auto.dev. test/setup.ts now points HOME/USERPROFILE at a fresh temp dir for every run, and the clearCredentials test also mocks fetch so no revoke can escape. Co-Authored-By: Claude Fable 5 --- test/auth/auth-origin-headers.test.ts | 6 ++++++ test/setup.ts | 10 ++++++++++ vitest.config.ts | 1 + 3 files changed, 17 insertions(+) create mode 100644 test/setup.ts diff --git a/test/auth/auth-origin-headers.test.ts b/test/auth/auth-origin-headers.test.ts index 720c4f9..8dc05f7 100644 --- a/test/auth/auth-origin-headers.test.ts +++ b/test/auth/auth-origin-headers.test.ts @@ -129,6 +129,12 @@ describe('getValidToken delegates to upstream refresh with clientType', () => { describe('clearCredentials accepts clientType', () => { it('accepts cli clientType without throwing', async () => { + // HOME is sandboxed by test/setup.ts, so this exercises the real code path + // without touching (or revoking) the developer's stored credentials. + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })) await expect(clearCredentials('cli')).resolves.toBeUndefined() + fetchSpy.mockRestore() }) }) diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..24e4024 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,10 @@ +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +// Sandbox HOME so no test can ever read, revoke, or delete the developer's real +// stored credentials (~/.id.org.ai/token, ~/.oauth.do/token). clearCredentials() +// and the oauth module's default storage both resolve paths from os.homedir(). +const sandboxHome = mkdtempSync(join(tmpdir(), 'auto-dev-sdk-test-home-')) +process.env.HOME = sandboxHome +process.env.USERPROFILE = sandboxHome diff --git a/vitest.config.ts b/vitest.config.ts index 345cab9..7c7bea7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: ['./test/setup.ts'], }, resolve: { alias: {