From bc5baa5fe35f140c822763d91b55495f15f768f4 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 12:54:47 -0600 Subject: [PATCH 01/30] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20mcp:=20migrate=20to?= =?UTF-8?q?=20Eden=20Treaty=20+=20scoped=20tools=20(admin,=20flag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the raw HTTP wrapper with two typed Eden Treaty clients (user, admin) so MCP tools become thin path-syntax wrappers over the API and inherit its type graph end-to-end. The user wanted to thicken the API and keep edge apps lean; this is the MCP half of that. User tools: packs, catalog, trips, weather, knowledge, trail-conditions, trails (existing, rewritten) plus auth, user, feed, packTemplates, seasons, wildlife, alltrails, upload, guides, ai (new). Admin tools: stats, users-list, packs-list, catalog-list (CRUD), trails search/get/geometry, trail-conditions, platform analytics, catalog analytics, ETL ops. Scoped — invisible until admin_login mints a JWT or X-PackRat-Admin-Token is supplied. Feature flags: registerFlaggedTool(flag, ...) keeps a tool hidden until the flag appears in MCP_FEATURE_FLAGS env or setFeatureFlag toggles it on at runtime. Backed by MCP SDK's enable/disable + tools/list_changed. ACL error layer: call() maps 401/403/404/409/422/429 from Treaty's { data, error, status } into actionable MCP tool errors, including admin-aware messages when requiresAdmin: true. Obsolete __tests__ removed — they targeted the raw client surface and will be rewritten against the Treaty mock in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/mcp/src/__tests__/auth.test.ts | 412 ----------------- packages/mcp/src/__tests__/client.test.ts | 226 ---------- packages/mcp/src/__tests__/helpers.ts | 111 ----- .../mcp/src/__tests__/tools/catalog.test.ts | 200 --------- .../mcp/src/__tests__/tools/knowledge.test.ts | 161 ------- .../mcp/src/__tests__/tools/packs.test.ts | 292 ------------ .../__tests__/tools/trail-conditions.test.ts | 152 ------- .../mcp/src/__tests__/tools/trips.test.ts | 182 -------- .../mcp/src/__tests__/tools/weather.test.ts | 133 ------ packages/mcp/src/client.ts | 199 +++++++- packages/mcp/src/index.ts | 160 ++++++- packages/mcp/src/resources.ts | 159 +++---- packages/mcp/src/tools/admin.ts | 425 ++++++++++++++++++ packages/mcp/src/tools/ai.ts | 50 +++ packages/mcp/src/tools/alltrails.ts | 19 + packages/mcp/src/tools/auth.ts | 80 ++++ packages/mcp/src/tools/catalog.ts | 245 ++++++---- packages/mcp/src/tools/feed.ts | 145 ++++++ packages/mcp/src/tools/guides.ts | 72 +++ packages/mcp/src/tools/knowledge.ts | 111 +---- packages/mcp/src/tools/packTemplates.ts | 237 ++++++++++ packages/mcp/src/tools/packs.ts | 380 +++++++++++----- packages/mcp/src/tools/seasons.ts | 23 + packages/mcp/src/tools/trail-conditions.ts | 179 +++++--- packages/mcp/src/tools/trails.ts | 135 ++---- packages/mcp/src/tools/trips.ts | 176 +++----- packages/mcp/src/tools/upload.ts | 25 ++ packages/mcp/src/tools/user.ts | 37 ++ packages/mcp/src/tools/weather.ts | 111 +++-- packages/mcp/src/tools/wildlife.ts | 18 + packages/mcp/src/types.ts | 51 ++- 31 files changed, 2232 insertions(+), 2674 deletions(-) delete mode 100644 packages/mcp/src/__tests__/auth.test.ts delete mode 100644 packages/mcp/src/__tests__/client.test.ts delete mode 100644 packages/mcp/src/__tests__/helpers.ts delete mode 100644 packages/mcp/src/__tests__/tools/catalog.test.ts delete mode 100644 packages/mcp/src/__tests__/tools/knowledge.test.ts delete mode 100644 packages/mcp/src/__tests__/tools/packs.test.ts delete mode 100644 packages/mcp/src/__tests__/tools/trail-conditions.test.ts delete mode 100644 packages/mcp/src/__tests__/tools/trips.test.ts delete mode 100644 packages/mcp/src/__tests__/tools/weather.test.ts create mode 100644 packages/mcp/src/tools/admin.ts create mode 100644 packages/mcp/src/tools/ai.ts create mode 100644 packages/mcp/src/tools/alltrails.ts create mode 100644 packages/mcp/src/tools/auth.ts create mode 100644 packages/mcp/src/tools/feed.ts create mode 100644 packages/mcp/src/tools/guides.ts create mode 100644 packages/mcp/src/tools/packTemplates.ts create mode 100644 packages/mcp/src/tools/seasons.ts create mode 100644 packages/mcp/src/tools/upload.ts create mode 100644 packages/mcp/src/tools/user.ts create mode 100644 packages/mcp/src/tools/wildlife.ts diff --git a/packages/mcp/src/__tests__/auth.test.ts b/packages/mcp/src/__tests__/auth.test.ts deleted file mode 100644 index 1568b2e800..0000000000 --- a/packages/mcp/src/__tests__/auth.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * Tests for the PackRat MCP Worker OAuth flow. - * - * The worker is now wrapped with OAuthProvider, which: - * - Serves GET/POST /token, POST /register, /.well-known/* automatically - * - Routes /mcp (and sub-paths) to mcpApiHandler after token validation - * - Routes everything else to PackRatAuthHandler (/, /health, /authorize, /login, /callback) - * - * Because OAuthProvider requires a real KV namespace (OAUTH_KV) and performs - * cryptographic operations, we test the auth handler sub-units in isolation - * and use a lightweight integration harness that mocks OAuthProvider + KV. - */ - -import { describe, expect, it, vi } from 'vitest'; - -// ── Mock cloudflare:workers before any imports ──────────────────────────────── - -vi.mock('cloudflare:workers', () => ({ - WorkerEntrypoint: class {}, - DurableObject: class {}, -})); - -// ── Mock agents/mcp ─────────────────────────────────────────────────────────── - -vi.mock('agents/mcp', () => { - class McpAgent { - fetch(_request: Request): Promise { - return Promise.resolve(new Response('{}', { status: 200 })); - } - static serve(_path: string) { - return { - fetch: vi.fn().mockResolvedValue(new Response('{"jsonrpc":"2.0"}', { status: 200 })), - }; - } - } - return { McpAgent }; -}); - -vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ - McpServer: class { - registerTool = vi.fn(); - registerResource = vi.fn(); - registerPrompt = vi.fn(); - }, - ResourceTemplate: class { - constructor( - public uriTemplate: string, - _opts?: unknown, - ) {} - }, -})); - -// ── Mock OAuthProvider — returns a simple fetch handler for testing ──────────── - -vi.mock('@cloudflare/workers-oauth-provider', () => { - class OAuthProvider { - private opts: Record; - constructor(opts: Record) { - this.opts = opts; - } - // biome-ignore lint/complexity/useMaxParams: mirrors Cloudflare Workers fetch signature - async fetch(request: Request, env: Record, ctx: unknown): Promise { - const url = new URL(request.url); - - // Simulate OAuthProvider routing: - // - /token → token endpoint (handled by OAuthProvider itself) - // - /mcp* → apiHandler (with props injected) - // - others → defaultHandler - - if (url.pathname === '/token') { - // Simulate token endpoint — return a minimal token response - return Response.json({ access_token: 'test-token', token_type: 'Bearer' }); - } - - if (url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) { - const authHeader = request.headers.get('Authorization'); - const token = authHeader?.match(/^Bearer\s+(\S+)/i)?.[1] ?? ''; - - if (!token) { - return Response.json( - { error: 'unauthorized', error_description: 'Missing access token' }, - { - status: 401, - headers: { 'WWW-Authenticate': 'Bearer realm="packrat-mcp"' }, - }, - ); - } - - // Call the apiHandler with a props-augmented ctx - const apiHandler = this.opts.apiHandler as { - fetch: (req: Request, env: unknown, ctx: unknown) => Promise; - }; - const augCtx = Object.assign({}, ctx, { props: { betterAuthToken: token, userId: 'u1' } }); - return apiHandler.fetch(request, env, augCtx); - } - - // Route all other paths to defaultHandler - const defaultHandler = this.opts.defaultHandler as { - fetch: (req: Request, env: unknown) => Promise; - }; - return defaultHandler.fetch(request, env); - } - } - return { OAuthProvider, default: OAuthProvider }; -}); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function makeKv(initial: Record = {}): KVNamespace { - const store = new Map( - Object.entries(initial).map(([k, v]) => [k, { value: v }]), - ); - return { - get: vi.fn(async (key: string) => store.get(key)?.value ?? null), - // biome-ignore lint/complexity/useMaxParams: mirrors KVNamespace.put signature - put: vi.fn(async (key: string, value: string, opts?: { expirationTtl?: number }) => { - store.set(key, { value, expiration: opts?.expirationTtl }); - }), - delete: vi.fn(async (key: string) => { - store.delete(key); - }), - getWithMetadata: vi.fn(), - list: vi.fn(), - } as unknown as KVNamespace; -} - -function makeOAuthProvider() { - return { - parseAuthRequest: vi.fn().mockResolvedValue({ - responseType: 'code', - clientId: 'test-client', - redirectUri: 'https://client.example.com/cb', - scope: ['mcp'], - state: 'xyz', - }), - lookupClient: vi.fn().mockResolvedValue({ - clientId: 'test-client', - redirectUris: ['https://client.example.com/cb'], - }), - completeAuthorization: vi.fn().mockResolvedValue({ - redirectTo: 'https://client.example.com/cb?code=abc&state=xyz', - }), - }; -} - -function makeEnv(kvOverrides: Record = {}): import('../types').Env { - return { - PACKRAT_API_URL: 'https://api.example.com', - OAUTH_KV: makeKv(kvOverrides), - OAUTH_PROVIDER: makeOAuthProvider() as unknown as import('../types').Env['OAUTH_PROVIDER'], - PackRatMCP: {} as unknown as DurableObjectNamespace, - }; -} - -function req(url: string, init: RequestInit = {}): Request { - return new Request(url, init); -} - -// ── Import worker after all mocks ───────────────────────────────────────────── - -const { default: worker } = await import('../index'); -const fakeCtx = { - waitUntil: vi.fn(), - passThroughOnException: vi.fn(), -} as unknown as ExecutionContext; - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe('health check', () => { - it('returns 200 for GET /', async () => { - const env = makeEnv(); - const res = await worker.fetch(req('https://mcp.example.com/'), env, fakeCtx); - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - expect(body.status).toBe('ok'); - expect(body.service).toBe('packrat-mcp'); - }); - - it('returns 200 for GET /health', async () => { - const env = makeEnv(); - const res = await worker.fetch(req('https://mcp.example.com/health'), env, fakeCtx); - expect(res.status).toBe(200); - }); -}); - -describe('/mcp auth guard', () => { - it('returns 401 when Authorization header is absent', async () => { - const env = makeEnv(); - const res = await worker.fetch(req('https://mcp.example.com/mcp'), env, fakeCtx); - expect(res.status).toBe(401); - expect(res.headers.get('WWW-Authenticate')).toMatch(/Bearer/); - }); - - it('returns 401 for empty Bearer token', async () => { - const env = makeEnv(); - const res = await worker.fetch( - req('https://mcp.example.com/mcp', { headers: { Authorization: 'Bearer ' } }), - env, - fakeCtx, - ); - expect(res.status).toBe(401); - }); - - it('forwards request to McpAgent when a valid Bearer token is provided', async () => { - const env = makeEnv(); - const res = await worker.fetch( - req('https://mcp.example.com/mcp', { - method: 'POST', - headers: { - Authorization: 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 }), - }), - env, - fakeCtx, - ); - expect(res.status).toBe(200); - }); -}); - -describe('PackRatAuthHandler – /authorize', () => { - it('redirects to /login with a generated state key', async () => { - const env = makeEnv(); - const res = await worker.fetch( - req( - 'https://mcp.example.com/authorize?response_type=code&client_id=test-client&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&scope=mcp&state=abc', - ), - env, - fakeCtx, - ); - expect(res.status).toBe(302); - const location = res.headers.get('Location') ?? ''; - expect(location).toMatch(/\/login\?state=/); - }); - - it('stores OAuth state in KV', async () => { - const env = makeEnv(); - await worker.fetch( - req( - 'https://mcp.example.com/authorize?response_type=code&client_id=test-client&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&scope=mcp&state=abc', - ), - env, - fakeCtx, - ); - expect(env.OAUTH_KV.put).toHaveBeenCalledWith( - expect.stringMatching(/^oauth_state:/), - expect.any(String), - expect.objectContaining({ expirationTtl: 600 }), - ); - }); -}); - -describe('PackRatAuthHandler – /login', () => { - it('GET /login serves an HTML form', async () => { - const env = makeEnv(); - const res = await worker.fetch( - req('https://mcp.example.com/login?state=some-key'), - env, - fakeCtx, - ); - expect(res.status).toBe(200); - expect(res.headers.get('Content-Type')).toMatch(/text\/html/); - const body = await res.text(); - expect(body).toContain(' { - const stateKey = 'test-state-key'; - const env = makeEnv({ - [`oauth_state:${stateKey}`]: JSON.stringify({ - clientId: 'test-client', - scope: ['mcp'], - state: 'xyz', - redirectUri: 'https://client.example.com/cb', - responseType: 'code', - }), - }); - - const origFetch = globalThis.fetch; - globalThis.fetch = vi - .fn() - .mockResolvedValue( - new Response( - JSON.stringify({ user: { id: 'user-123' }, session: { token: 'ba-token-abc' } }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ) as unknown as typeof fetch; - - const form = new URLSearchParams({ - email: 'test@example.com', - password: 'secret', - state: stateKey, - }); - const res = await worker.fetch( - req('https://mcp.example.com/login', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: form.toString(), - }), - env, - fakeCtx, - ); - - expect(res.status).toBe(302); - expect(res.headers.get('Location')).toMatch(/\/callback\?state=/); - expect(env.OAUTH_KV.put).toHaveBeenCalledWith( - `session:${stateKey}`, - expect.stringContaining('ba-token-abc'), - expect.any(Object), - ); - - globalThis.fetch = origFetch; - }); - - it('POST /login with invalid credentials returns 401 HTML', async () => { - const stateKey = 'test-state-key'; - const env = makeEnv({ - [`oauth_state:${stateKey}`]: JSON.stringify({ - clientId: 'c', - scope: ['mcp'], - state: 'x', - redirectUri: 'https://x.com', - responseType: 'code', - }), - }); - - const origFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: 'invalid_credentials' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch; - - const form = new URLSearchParams({ - email: 'bad@example.com', - password: 'wrong', - state: stateKey, - }); - const res = await worker.fetch( - req('https://mcp.example.com/login', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: form.toString(), - }), - env, - fakeCtx, - ); - - expect(res.status).toBe(401); - const body = await res.text(); - expect(body).toContain('Invalid email or password'); - - globalThis.fetch = origFetch; - }); -}); - -describe('PackRatAuthHandler – /callback', () => { - it('completes OAuth authorization and redirects', async () => { - const stateKey = 'cb-state-key'; - const env = makeEnv({ - [`oauth_state:${stateKey}`]: JSON.stringify({ - clientId: 'test-client', - scope: ['mcp'], - state: 'xyz', - redirectUri: 'https://client.example.com/cb', - responseType: 'code', - }), - [`session:${stateKey}`]: JSON.stringify({ token: 'ba-token', userId: 'user-123' }), - }); - - const res = await worker.fetch( - req(`https://mcp.example.com/callback?state=${stateKey}`), - env, - fakeCtx, - ); - - expect(res.status).toBe(302); - expect(res.headers.get('Location')).toContain('code=abc'); - expect(env.OAUTH_PROVIDER.completeAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-123', - props: { betterAuthToken: 'ba-token', userId: 'user-123' }, - }), - ); - }); - - it('returns 400 when state is missing from KV', async () => { - const env = makeEnv(); // empty KV - - const res = await worker.fetch( - req('https://mcp.example.com/callback?state=nonexistent'), - env, - fakeCtx, - ); - - expect(res.status).toBe(400); - const body = (await res.json()) as Record; - expect(body.error).toBe('invalid_request'); - }); -}); - -describe('unknown paths', () => { - it('returns 404 for unknown paths', async () => { - const env = makeEnv(); - const res = await worker.fetch(req('https://mcp.example.com/unknown'), env, fakeCtx); - expect(res.status).toBe(404); - }); -}); diff --git a/packages/mcp/src/__tests__/client.test.ts b/packages/mcp/src/__tests__/client.test.ts deleted file mode 100644 index 2c763e55b5..0000000000 --- a/packages/mcp/src/__tests__/client.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiError, err, ok, PackRatApiClient } from '../client'; - -// ── ok() / err() helpers ────────────────────────────────────────────────────── - -describe('ok()', () => { - it('wraps data as JSON text content', () => { - const result = ok({ id: 1, name: 'My Pack' }); - expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe('text'); - expect(JSON.parse(result.content[0].text)).toEqual({ id: 1, name: 'My Pack' }); - }); - - it('handles arrays', () => { - const result = ok([1, 2, 3]); - expect(JSON.parse(result.content[0].text)).toEqual([1, 2, 3]); - }); - - it('handles null', () => { - const result = ok(null); - expect(result.content[0].text).toBe('null'); - }); -}); - -describe('err()', () => { - it('formats an ApiError with status code', () => { - const result = err(new ApiError('Not Found', { status: 404, body: { error: 'Not Found' } })); - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Error: API Error (404): Not Found'); - }); - - it('formats a generic Error', () => { - const result = err(new Error('Something broke')); - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Error: Something broke'); - }); - - it('formats a string error', () => { - const result = err('raw string error'); - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Error: raw string error'); - }); -}); - -// ── ApiError ────────────────────────────────────────────────────────────────── - -describe('ApiError', () => { - it('sets name, status, and body', () => { - const body = { error: 'Unauthorized' }; - const e = new ApiError('Unauthorized', { status: 401, body }); - expect(e.name).toBe('ApiError'); - expect(e.message).toBe('Unauthorized'); - expect(e.status).toBe(401); - expect(e.body).toBe(body); - expect(e instanceof Error).toBe(true); - }); -}); - -// ── PackRatApiClient ────────────────────────────────────────────────────────── - -describe('PackRatApiClient', () => { - const BASE = 'https://api.example.com'; - let token = 'test-jwt-token'; - let client: PackRatApiClient; - let fetchMock: ReturnType; - - beforeEach(() => { - token = 'test-jwt-token'; // reset between tests to avoid mutation leaking - fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - client = new PackRatApiClient(BASE, () => token); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - function mockResponse(body: unknown, status = 200): Response { - return { - ok: status >= 200 && status < 300, - status, - statusText: status === 200 ? 'OK' : 'Error', - text: async () => JSON.stringify(body), - } as unknown as Response; - } - - describe('GET', () => { - it('sends a GET request with auth header', async () => { - fetchMock.mockResolvedValue(mockResponse({ items: [] })); - - await client.get('/packs'); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE}/packs`); - expect(init.method).toBe('GET'); - expect((init.headers as Record).Authorization).toBe(`Bearer ${token}`); - }); - - it('appends query params', async () => { - fetchMock.mockResolvedValue(mockResponse([])); - - await client.get('/packs', { limit: 10, offset: 0, category: 'backpacking' }); - - const [url] = fetchMock.mock.calls[0] as [string]; - const parsed = new URL(url); - expect(parsed.searchParams.get('limit')).toBe('10'); - expect(parsed.searchParams.get('offset')).toBe('0'); - expect(parsed.searchParams.get('category')).toBe('backpacking'); - }); - - it('skips undefined params', async () => { - fetchMock.mockResolvedValue(mockResponse([])); - - await client.get('/packs', { limit: 10, category: undefined }); - - const [url] = fetchMock.mock.calls[0] as [string]; - const parsed = new URL(url); - expect(parsed.searchParams.has('category')).toBe(false); - expect(parsed.searchParams.get('limit')).toBe('10'); - }); - - it('omits Authorization header when token is empty', async () => { - token = ''; - fetchMock.mockResolvedValue(mockResponse({})); - - await client.get('/public'); - - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect((init.headers as Record).Authorization).toBeUndefined(); - }); - - it('throws ApiError on non-ok response with JSON error body', async () => { - fetchMock.mockResolvedValue(mockResponse({ error: 'Not found' }, 404)); - - await expect(client.get('/packs/nope')).rejects.toThrow(ApiError); - - try { - await client.get('/packs/nope'); - } catch (e) { - expect(e instanceof ApiError).toBe(true); - expect((e as ApiError).status).toBe(404); - expect((e as ApiError).message).toBe('Not found'); - } - }); - - it('throws ApiError with HTTP status message when body has no error field', async () => { - fetchMock.mockResolvedValue(mockResponse({ message: 'gone' }, 410)); - - await expect(client.get('/gone')).rejects.toThrow('HTTP 410:'); - }); - - it('returns parsed JSON on success', async () => { - const pack = { id: 'p_1', name: 'Test Pack' }; - fetchMock.mockResolvedValue(mockResponse(pack)); - - const result = await client.get('/packs/p_1'); - expect(result).toEqual(pack); - }); - }); - - describe('POST', () => { - it('sends POST with JSON body', async () => { - fetchMock.mockResolvedValue(mockResponse({ id: 'p_new' })); - - await client.post('/packs', { name: 'New Pack', category: 'backpacking' }); - - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE}/packs`); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toEqual({ - name: 'New Pack', - category: 'backpacking', - }); - }); - - it('sends POST with no body when omitted', async () => { - fetchMock.mockResolvedValue(mockResponse({ ok: true })); - - await client.post('/action'); - - const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(init.body).toBeUndefined(); - }); - }); - - describe('PATCH', () => { - it('sends PATCH with JSON body', async () => { - fetchMock.mockResolvedValue(mockResponse({ id: 'p_1' })); - - await client.patch('/packs/p_1', { name: 'Updated' }); - - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE}/packs/p_1`); - expect(init.method).toBe('PATCH'); - expect(JSON.parse(init.body as string)).toEqual({ name: 'Updated' }); - }); - }); - - describe('DELETE', () => { - it('sends DELETE request', async () => { - fetchMock.mockResolvedValue(mockResponse({ deleted: true })); - - await client.delete('/packs/p_1'); - - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe(`${BASE}/packs/p_1`); - expect(init.method).toBe('DELETE'); - }); - }); - - describe('non-JSON response', () => { - it('returns raw string when response is not JSON', async () => { - const raw = { - ok: true, - status: 200, - statusText: 'OK', - text: async () => 'plain text response', - } as unknown as Response; - fetchMock.mockResolvedValue(raw); - - const result = await client.get('/text-endpoint'); - expect(result).toBe('plain text response'); - }); - }); -}); diff --git a/packages/mcp/src/__tests__/helpers.ts b/packages/mcp/src/__tests__/helpers.ts deleted file mode 100644 index 0262acd54a..0000000000 --- a/packages/mcp/src/__tests__/helpers.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Test helpers — create lightweight fakes for McpServer and PackRatApiClient. - * - * Tool handlers are extracted from the `registerTool` calls so they can be - * invoked directly in tests without spinning up a Durable Object or MCP session. - */ -import { vi } from 'vitest'; -import type { PackRatApiClient } from '../client'; -import type { PackRatMCP } from '../index'; - -/** Captured tool registration. */ -export interface RegisteredTool { - name: string; - config: { description?: string; inputSchema: Record }; - handler: (args: Record) => Promise; -} - -/** Captured resource registration. */ -export interface RegisteredResource { - name: string; -} - -/** Captured prompt registration. */ -export interface RegisteredPrompt { - name: string; -} - -/** - * Build a fake McpServer that records `registerTool` calls. - * Returns the registry map so tests can pull out and invoke handlers directly. - */ -export function buildMockServer() { - const tools = new Map(); - const resources = new Map(); - const prompts = new Map(); - - const server = { - registerTool: vi.fn( - // biome-ignore lint/complexity/useMaxParams: mirrors MCP SDK's positional registerTool signature - (name: string, config: RegisteredTool['config'], handler: RegisteredTool['handler']) => { - tools.set(name, { name, config, handler }); - }, - ), - registerResource: vi.fn((name: string, ..._rest: unknown[]) => { - resources.set(name, { name }); - }), - registerPrompt: vi.fn((name: string, ..._rest: unknown[]) => { - prompts.set(name, { name }); - }), - }; - - return { server, tools, resources, prompts }; -} - -/** - * Build a mock PackRatApiClient where every method is a vi.fn(). - */ -export function buildMockApiClient() { - return { - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - patch: vi.fn(), - delete: vi.fn(), - } as unknown as PackRatApiClient; -} - -/** - * Build a minimal fake PackRatMCP agent with mocked server and api. - */ -export function buildMockAgent(): { - agent: PackRatMCP; - tools: Map; - resources: Map; - prompts: Map; - api: PackRatApiClient; -} { - const { server, tools, resources, prompts } = buildMockServer(); - const api = buildMockApiClient(); - - const agent = { - server, - api, - state: { authToken: 'test-token' }, - env: { PACKRAT_API_URL: 'https://api.example.com', PackRatMCP: {} }, - } as unknown as PackRatMCP; - - return { agent, tools, resources, prompts, api }; -} - -/** Invoke a registered tool by name, returning its result. */ -export async function callTool(options: { - tools: Map; - name: string; - args: Record; -}): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { - const { tools, name, args } = options; - const tool = tools.get(name); - if (!tool) throw new Error(`Tool "${name}" not registered`); - return tool.handler(args) as Promise<{ - content: Array<{ type: string; text: string }>; - isError?: boolean; - }>; -} - -/** Parse the JSON text from a tool result's first content block. */ -export function parseToolResult(result: { - content: Array<{ type: string; text: string }>; -}): unknown { - return JSON.parse(result.content[0].text); -} diff --git a/packages/mcp/src/__tests__/tools/catalog.test.ts b/packages/mcp/src/__tests__/tools/catalog.test.ts deleted file mode 100644 index 35051d6345..0000000000 --- a/packages/mcp/src/__tests__/tools/catalog.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PackRatApiClient } from '../../client'; -import { ApiError } from '../../client'; -import { registerCatalogTools } from '../../tools/catalog'; -import type { RegisteredTool } from '../helpers'; -import { buildMockAgent, callTool, parseToolResult } from '../helpers'; - -describe('catalog tools', () => { - let api: PackRatApiClient; - let tools: Map; - - beforeEach(() => { - const mock = buildMockAgent(); - api = mock.api; - tools = mock.tools; - registerCatalogTools(mock.agent); - }); - - // ── search_gear_catalog ───────────────────────────────────────────────────── - - describe('search_gear_catalog', () => { - it('is registered', () => { - expect(tools.has('search_gear_catalog')).toBe(true); - }); - - it('calls GET /catalog with all params', async () => { - vi.mocked(api.get).mockResolvedValue({ items: [] }); - - await callTool({ - tools, - name: 'search_gear_catalog', - args: { - query: 'ultralight tent', - category: 'tents', - limit: 5, - page: 1, - sort_by: 'price', - sort_order: 'asc', - }, - }); - - expect(api.get).toHaveBeenCalledWith('/catalog', { - q: 'ultralight tent', - category: 'tents', - limit: 5, - page: 1, - 'sort[field]': 'price', - 'sort[order]': 'asc', - }); - }); - - it('returns error result on API failure', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Server Error', { status: 500, body: {} })); - - const result = await callTool({ - tools, - name: 'search_gear_catalog', - args: { limit: 10, page: 1, sort_order: 'asc' }, - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('500'); - }); - }); - - // ── semantic_gear_search ──────────────────────────────────────────────────── - - describe('semantic_gear_search', () => { - it('calls GET /catalog/vector-search', async () => { - vi.mocked(api.get).mockResolvedValue({ items: [], total: 0 }); - - await callTool({ - tools, - name: 'semantic_gear_search', - args: { query: 'warm puffy jacket for winter camping', limit: 5 }, - }); - - expect(api.get).toHaveBeenCalledWith('/catalog/vector-search', { - q: 'warm puffy jacket for winter camping', - limit: 5, - }); - }); - - it('returns items from the semantic search', async () => { - const items = [{ id: 1, name: "Arc'teryx Atom LT" }]; - vi.mocked(api.get).mockResolvedValue({ items }); - - const result = await callTool({ - tools, - name: 'semantic_gear_search', - args: { query: 'midlayer fleece', limit: 8 }, - }); - - expect(parseToolResult(result)).toEqual({ items }); - }); - }); - - // ── get_catalog_item ──────────────────────────────────────────────────────── - - describe('get_catalog_item', () => { - it('calls GET /catalog/:id', async () => { - const item = { id: 42, name: 'Big Agnes Copper Spur' }; - vi.mocked(api.get).mockResolvedValue(item); - - const result = await callTool({ tools, name: 'get_catalog_item', args: { item_id: 42 } }); - - expect(api.get).toHaveBeenCalledWith('/catalog/42'); - expect(parseToolResult(result)).toEqual(item); - }); - - it('propagates 404 as error result', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', { status: 404, body: {} })); - - const result = await callTool({ tools, name: 'get_catalog_item', args: { item_id: 9999 } }); - - expect(result.isError).toBe(true); - }); - }); - - // ── list_gear_categories ──────────────────────────────────────────────────── - - describe('list_gear_categories', () => { - it('calls GET /catalog/categories with no params', async () => { - vi.mocked(api.get).mockResolvedValue([{ name: 'tents', count: 120 }]); - - const result = await callTool({ tools, name: 'list_gear_categories', args: {} }); - - expect(api.get).toHaveBeenCalledWith('/catalog/categories'); - expect(Array.isArray(parseToolResult(result))).toBe(true); - }); - }); - - // ── compare_gear_items ────────────────────────────────────────────────────── - - describe('compare_gear_items', () => { - it('fetches all items and returns sorted comparison', async () => { - // Items returned in order of ids 1, 2, 3 - vi.mocked(api.get) - .mockResolvedValueOnce({ - id: 1, - name: 'Heavy Tent', - brand: 'GenericBrand', - category: 'tents', - weight: 2000, - price: 20000, - ratingValue: 4.0, - ratingCount: 50, - productUrl: 'https://x.com', - }) - .mockResolvedValueOnce({ - id: 2, - name: 'Ultralight Tent', - brand: 'GossamerGear', - category: 'tents', - weight: 700, - price: 55000, - ratingValue: 4.8, - ratingCount: 200, - productUrl: 'https://y.com', - }) - .mockResolvedValueOnce({ - id: 3, - name: 'Mid Tent', - brand: 'MSR', - category: 'tents', - weight: 1200, - price: 35000, - ratingValue: 4.5, - ratingCount: 150, - productUrl: 'https://z.com', - }); - - const result = await callTool({ - tools, - name: 'compare_gear_items', - args: { item_ids: [1, 2, 3] }, - }); - const comparison = parseToolResult(result) as Record; - - expect((comparison.items as unknown[]).length).toBe(3); - // sorted by weight asc → Ultralight first - expect((comparison.items as Array>)[0].name).toBe('Ultralight Tent'); - expect(comparison.lightest).toBe('Ultralight Tent'); - expect(comparison.highestRated).toBe('Ultralight Tent'); - expect(comparison.cheapest).toBe('Heavy Tent'); - }); - - it('returns error result if any item fetch fails', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', { status: 404, body: {} })); - - const result = await callTool({ - tools, - name: 'compare_gear_items', - args: { item_ids: [1, 2] }, - }); - - expect(result.isError).toBe(true); - }); - }); -}); diff --git a/packages/mcp/src/__tests__/tools/knowledge.test.ts b/packages/mcp/src/__tests__/tools/knowledge.test.ts deleted file mode 100644 index 1ad7fa0678..0000000000 --- a/packages/mcp/src/__tests__/tools/knowledge.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PackRatApiClient } from '../../client'; -import { ApiError } from '../../client'; -import { registerKnowledgeTools } from '../../tools/knowledge'; -import type { RegisteredTool } from '../helpers'; -import { buildMockAgent, callTool, parseToolResult } from '../helpers'; - -describe('knowledge tools', () => { - let api: PackRatApiClient; - let tools: Map; - - beforeEach(() => { - const mock = buildMockAgent(); - api = mock.api; - tools = mock.tools; - registerKnowledgeTools(mock.agent); - }); - - // ── search_outdoor_guides ─────────────────────────────────────────────────── - - describe('search_outdoor_guides', () => { - it('is registered', () => { - expect(tools.has('search_outdoor_guides')).toBe(true); - }); - - it('calls GET /ai/rag-search with query and limit', async () => { - const guides = { results: [{ title: 'Bear Hang Guide', content: '...' }] }; - vi.mocked(api.get).mockResolvedValue(guides); - - const result = await callTool({ - tools, - name: 'search_outdoor_guides', - args: { - query: 'how to set up a bear hang', - limit: 3, - }, - }); - - expect(api.get).toHaveBeenCalledWith('/ai/rag-search', { - q: 'how to set up a bear hang', - limit: 3, - }); - expect(parseToolResult(result)).toEqual(guides); - }); - - it('returns error result on API failure', async () => { - vi.mocked(api.get).mockRejectedValue( - new ApiError('Service Unavailable', { status: 503, body: {} }), - ); - - const result = await callTool({ - tools, - name: 'search_outdoor_guides', - args: { - query: 'water treatment methods', - limit: 5, - }, - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('503'); - }); - }); - - // ── web_search ────────────────────────────────────────────────────────────── - - describe('web_search', () => { - it('is registered', () => { - expect(tools.has('web_search')).toBe(true); - }); - - it('calls GET /ai/web-search with query', async () => { - const webResult = { answer: 'JMT permits are available...', sources: [] }; - vi.mocked(api.get).mockResolvedValue(webResult); - - const result = await callTool({ - tools, - name: 'web_search', - args: { - query: 'John Muir Trail permit availability 2025', - }, - }); - - expect(api.get).toHaveBeenCalledWith('/ai/web-search', { - q: 'John Muir Trail permit availability 2025', - }); - expect(parseToolResult(result)).toEqual(webResult); - }); - - it('returns error when network fails', async () => { - vi.mocked(api.get).mockRejectedValue(new Error('fetch failed')); - - const result = await callTool({ tools, name: 'web_search', args: { query: 'test query' } }); - - expect(result.isError).toBe(true); - }); - }); - - // ── execute_sql_query ─────────────────────────────────────────────────────── - - describe('execute_sql_query', () => { - it('is registered', () => { - expect(tools.has('execute_sql_query')).toBe(true); - }); - - it('calls POST /ai/execute-sql with query and limit', async () => { - const rows = [{ name: 'Zpacks Duplex', weight: 510 }]; - vi.mocked(api.post).mockResolvedValue({ rows, rowCount: 1 }); - - const sql = - "SELECT name, weight FROM catalog_items WHERE category = 'tents' ORDER BY weight ASC LIMIT 5"; - const result = await callTool({ - tools, - name: 'execute_sql_query', - args: { query: sql, limit: 50 }, - }); - - const [path, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(path).toBe('/ai/execute-sql'); - expect(body.query).toBe(sql); - expect(body.limit).toBe(50); - expect((parseToolResult(result) as Record).rowCount).toBe(1); - }); - - it('returns error for failed queries', async () => { - vi.mocked(api.post).mockRejectedValue( - new ApiError('Syntax error in SQL', { status: 400, body: {} }), - ); - - const result = await callTool({ - tools, - name: 'execute_sql_query', - args: { - query: 'SELECT * FROM nonexistent_table', - limit: 100, - }, - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('400'); - }); - }); - - // ── get_database_schema ───────────────────────────────────────────────────── - - describe('get_database_schema', () => { - it('is registered', () => { - expect(tools.has('get_database_schema')).toBe(true); - }); - - it('calls GET /ai/db-schema with no params', async () => { - const schema = { tables: [{ name: 'catalog_items', columns: ['id', 'name', 'weight'] }] }; - vi.mocked(api.get).mockResolvedValue(schema); - - const result = await callTool({ tools, name: 'get_database_schema', args: {} }); - - expect(api.get).toHaveBeenCalledWith('/ai/db-schema'); - expect(parseToolResult(result)).toEqual(schema); - }); - }); -}); diff --git a/packages/mcp/src/__tests__/tools/packs.test.ts b/packages/mcp/src/__tests__/tools/packs.test.ts deleted file mode 100644 index a4951119a9..0000000000 --- a/packages/mcp/src/__tests__/tools/packs.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PackRatApiClient } from '../../client'; -import { ApiError } from '../../client'; -import { registerPackTools } from '../../tools/packs'; -import type { RegisteredTool } from '../helpers'; -import { buildMockAgent, callTool, parseToolResult } from '../helpers'; - -describe('pack tools', () => { - let api: PackRatApiClient; - let tools: Map; - - beforeEach(() => { - const mock = buildMockAgent(); - api = mock.api; - tools = mock.tools; - registerPackTools(mock.agent); - }); - - // ── list_packs ────────────────────────────────────────────────────────────── - - describe('list_packs', () => { - it('is registered', () => { - expect(tools.has('list_packs')).toBe(true); - }); - - it('calls GET /packs with includePublic param', async () => { - const mockData = { items: [], total: 0 }; - vi.mocked(api.get).mockResolvedValue(mockData); - - const result = await callTool({ tools, name: 'list_packs', args: { include_public: true } }); - - expect(api.get).toHaveBeenCalledWith('/packs', { includePublic: 1 }); - expect(parseToolResult(result)).toEqual(mockData); - }); - - it('returns error result on API failure', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Forbidden', { status: 403, body: {} })); - - const result = await callTool({ tools, name: 'list_packs', args: {} }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('403'); - }); - }); - - // ── get_pack ──────────────────────────────────────────────────────────────── - - describe('get_pack', () => { - it('calls GET /packs/:id', async () => { - const pack = { id: 'p_abc', name: 'Test Pack', items: [] }; - vi.mocked(api.get).mockResolvedValue(pack); - - const result = await callTool({ tools, name: 'get_pack', args: { pack_id: 'p_abc' } }); - - expect(api.get).toHaveBeenCalledWith('/packs/p_abc'); - expect(parseToolResult(result)).toEqual(pack); - }); - - it('propagates 404 as error result', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', { status: 404, body: {} })); - - const result = await callTool({ tools, name: 'get_pack', args: { pack_id: 'nope' } }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('404'); - }); - }); - - // ── create_pack ───────────────────────────────────────────────────────────── - - describe('create_pack', () => { - it('calls POST /packs with mapped fields', async () => { - const created = { id: 'p_new', name: 'Summer Trek' }; - vi.mocked(api.post).mockResolvedValue(created); - - const result = await callTool({ - tools, - name: 'create_pack', - args: { - name: 'Summer Trek', - category: 'backpacking', - is_public: true, - tags: ['summer', 'california'], - }, - }); - - expect(api.post).toHaveBeenCalledOnce(); - const [path, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(path).toBe('/packs'); - expect(body.name).toBe('Summer Trek'); - expect(body.category).toBe('backpacking'); - expect(body.isPublic).toBe(true); - expect(body.tags).toEqual(['summer', 'california']); - expect(typeof body.id).toBe('string'); - expect((body.id as string).startsWith('p_')).toBe(true); - expect(parseToolResult(result)).toEqual(created); - }); - }); - - // ── update_pack ───────────────────────────────────────────────────────────── - - describe('update_pack', () => { - it('calls PUT /packs/:id with only provided fields', async () => { - vi.mocked(api.put).mockResolvedValue({ id: 'p_1' }); - - await callTool({ - tools, - name: 'update_pack', - args: { - pack_id: 'p_1', - name: 'Renamed Pack', - is_public: false, - }, - }); - - const [path, body] = vi.mocked(api.put).mock.calls[0] as [string, Record]; - expect(path).toBe('/packs/p_1'); - expect(body.name).toBe('Renamed Pack'); - expect(body.isPublic).toBe(false); - expect(body.category).toBeUndefined(); - }); - - it('does not include undefined optional fields', async () => { - vi.mocked(api.put).mockResolvedValue({ id: 'p_1' }); - - await callTool({ tools, name: 'update_pack', args: { pack_id: 'p_1', name: 'Only Name' } }); - - const [, body] = vi.mocked(api.put).mock.calls[0] as [string, Record]; - expect(body.description).toBeUndefined(); - expect(body.tags).toBeUndefined(); - }); - }); - - // ── delete_pack ───────────────────────────────────────────────────────────── - - describe('delete_pack', () => { - it('calls DELETE /packs/:id', async () => { - vi.mocked(api.delete).mockResolvedValue({ deleted: true }); - - const result = await callTool({ tools, name: 'delete_pack', args: { pack_id: 'p_del' } }); - - expect(api.delete).toHaveBeenCalledWith('/packs/p_del'); - expect(parseToolResult(result)).toEqual({ deleted: true }); - }); - }); - - // ── add_pack_item ─────────────────────────────────────────────────────────── - - describe('add_pack_item', () => { - it('calls POST /packs/:id/items with mapped fields', async () => { - vi.mocked(api.post).mockResolvedValue({ id: 'i_new' }); - - await callTool({ - tools, - name: 'add_pack_item', - args: { - pack_id: 'p_1', - name: 'Down Sleeping Bag', - category: 'sleep', - weight_grams: 900, - quantity: 1, - is_consumable: false, - is_worn: false, - }, - }); - - const [path, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(path).toBe('/packs/p_1/items'); - expect(body.name).toBe('Down Sleeping Bag'); - expect(body.weight).toBe(900); - expect(body.category).toBe('sleep'); - expect(body.quantity).toBe(1); - expect(typeof body.id).toBe('string'); - }); - }); - - // ── remove_pack_item ──────────────────────────────────────────────────────── - - describe('remove_pack_item', () => { - it('calls DELETE /packs/items/:itemId', async () => { - vi.mocked(api.delete).mockResolvedValue({ deleted: true }); - - await callTool({ tools, name: 'remove_pack_item', args: { item_id: 'i_99' } }); - - expect(api.delete).toHaveBeenCalledWith('/packs/items/i_99'); - }); - }); - - // ── analyze_pack_weight ───────────────────────────────────────────────────── - - describe('analyze_pack_weight', () => { - it('computes per-category weight breakdown', async () => { - vi.mocked(api.get).mockResolvedValue({ - totalWeight: 3200, - baseWeight: 2800, - wornWeight: 400, - consumableWeight: 0, - items: [ - { - name: 'Tent', - category: 'shelter', - weight: 1200, - quantity: 1, - worn: false, - consumable: false, - }, - { - name: 'Sleeping Bag', - category: 'sleep', - weight: 900, - quantity: 1, - worn: false, - consumable: false, - }, - { - name: 'Jacket', - category: 'clothing', - weight: 400, - quantity: 1, - worn: true, - consumable: false, - }, - { - name: 'Stove', - category: 'kitchen', - weight: 400, - quantity: 2, - worn: false, - consumable: false, - }, - ], - }); - - const result = await callTool({ - tools, - name: 'analyze_pack_weight', - args: { pack_id: 'p_1' }, - }); - const analysis = parseToolResult(result) as Record; - - expect(analysis.packId).toBe('p_1'); - expect(analysis.totalWeight).toBe(3200); - expect(analysis.itemCount).toBe(4); - - const categories = analysis.byCategory as Array>; - // shelter (1200g) should be first - expect(categories[0].category).toBe('shelter'); - expect(categories[0].totalGrams).toBe(1200); - // kitchen has 2×400 = 800g - const kitchen = categories.find((c) => c.category === 'kitchen'); - expect(kitchen?.totalGrams).toBe(800); - }); - - it('handles empty items array gracefully', async () => { - vi.mocked(api.get).mockResolvedValue({ items: [] }); - - const result = await callTool({ - tools, - name: 'analyze_pack_weight', - args: { pack_id: 'p_empty' }, - }); - const analysis = parseToolResult(result) as Record; - - expect(analysis.itemCount).toBe(0); - expect((analysis.byCategory as unknown[]).length).toBe(0); - }); - }); - - // ── analyze_pack_gaps ─────────────────────────────────────────────────────── - - describe('analyze_pack_gaps', () => { - it('calls POST /packs/:id/gap-analysis', async () => { - vi.mocked(api.post).mockResolvedValue({ missing: ['first_aid', 'navigation'] }); - - const result = await callTool({ - tools, - name: 'analyze_pack_gaps', - args: { - pack_id: 'p_1', - activity: 'backpacking', - duration_days: 3, - }, - }); - - const [path, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(path).toBe('/packs/p_1/gap-analysis'); - expect(body.activity).toBe('backpacking'); - expect(body.durationDays).toBe(3); - expect(parseToolResult(result)).toEqual({ missing: ['first_aid', 'navigation'] }); - }); - }); -}); diff --git a/packages/mcp/src/__tests__/tools/trail-conditions.test.ts b/packages/mcp/src/__tests__/tools/trail-conditions.test.ts deleted file mode 100644 index d0dc1e7dfc..0000000000 --- a/packages/mcp/src/__tests__/tools/trail-conditions.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PackRatApiClient } from '../../client'; -import { ApiError } from '../../client'; -import { registerTrailConditionTools } from '../../tools/trail-conditions'; -import type { RegisteredTool } from '../helpers'; -import { buildMockAgent, callTool, parseToolResult } from '../helpers'; - -describe('trail condition tools', () => { - let api: PackRatApiClient; - let tools: Map; - - beforeEach(() => { - const mock = buildMockAgent(); - api = mock.api; - tools = mock.tools; - registerTrailConditionTools(mock.agent); - }); - - // ── get_trail_conditions ──────────────────────────────────────────────────── - - describe('get_trail_conditions', () => { - it('is registered', () => { - expect(tools.has('get_trail_conditions')).toBe(true); - }); - - it('calls GET /trail-conditions with trailName and limit', async () => { - const reports = { items: [{ id: 1, overallCondition: 'good' }] }; - vi.mocked(api.get).mockResolvedValue(reports); - - const result = await callTool({ - tools, - name: 'get_trail_conditions', - args: { - trail_name: 'John Muir Trail', - limit: 5, - }, - }); - - expect(api.get).toHaveBeenCalledWith('/trail-conditions', { - trailName: 'John Muir Trail', - limit: 5, - }); - expect(parseToolResult(result)).toEqual(reports); - }); - - it('works without any params', async () => { - vi.mocked(api.get).mockResolvedValue({ items: [] }); - - await callTool({ tools, name: 'get_trail_conditions', args: { limit: 20 } }); - - const [, params] = vi.mocked(api.get).mock.calls[0] as [string, Record]; - expect(params.trailName).toBeUndefined(); - expect(params.limit).toBe(20); - }); - - it('returns error result on API failure', async () => { - vi.mocked(api.get).mockRejectedValue( - new ApiError('Internal Server Error', { status: 500, body: {} }), - ); - - const result = await callTool({ tools, name: 'get_trail_conditions', args: { limit: 10 } }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('500'); - }); - }); - - // ── submit_trail_condition ────────────────────────────────────────────────── - - describe('submit_trail_condition', () => { - it('is registered', () => { - expect(tools.has('submit_trail_condition')).toBe(true); - }); - - it('calls POST /trail-conditions with correctly mapped API fields', async () => { - vi.mocked(api.post).mockResolvedValue({ id: 'tcr_abc', submitted: true }); - - const result = await callTool({ - tools, - name: 'submit_trail_condition', - args: { - trail_name: 'Mt Whitney Trail', - trail_region: 'California', - surface: 'rocky', - overall_condition: 'good', - hazards: ['loose rocks', 'snow'], - water_crossings: 3, - water_crossing_difficulty: 'moderate', - notes: 'Trail is clear above 10k, some snow patches near the summit', - }, - }); - - const [path, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(path).toBe('/trail-conditions'); - expect(body.trailName).toBe('Mt Whitney Trail'); - expect(body.trailRegion).toBe('California'); - expect(body.surface).toBe('rocky'); - expect(body.overallCondition).toBe('good'); - expect(body.hazards).toEqual(['loose rocks', 'snow']); - expect(body.waterCrossings).toBe(3); - expect(body.waterCrossingDifficulty).toBe('moderate'); - expect(body.notes).toContain('summit'); - expect(body.photos).toEqual([]); - expect(typeof body.id).toBe('string'); - expect((body.id as string).startsWith('tcr_')).toBe(true); - expect(typeof body.localCreatedAt).toBe('string'); - expect(typeof body.localUpdatedAt).toBe('string'); - expect(parseToolResult(result)).toEqual({ id: 'tcr_abc', submitted: true }); - }); - - it('submits with defaults when optional fields are absent', async () => { - vi.mocked(api.post).mockResolvedValue({ id: 'tcr_2' }); - - await callTool({ - tools, - name: 'submit_trail_condition', - args: { - trail_name: 'Simple Trail', - surface: 'dirt', - overall_condition: 'excellent', - }, - }); - - const [, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(body.hazards).toEqual([]); - expect(body.waterCrossings).toBe(0); - expect(body.waterCrossingDifficulty).toBeNull(); - expect(body.notes).toBeNull(); - expect(body.trailRegion).toBeNull(); - expect(body.photos).toEqual([]); - }); - - it('returns error when user is not authenticated (401)', async () => { - vi.mocked(api.post).mockRejectedValue( - new ApiError('Unauthorized', { status: 401, body: {} }), - ); - - const result = await callTool({ - tools, - name: 'submit_trail_condition', - args: { - trail_name: 'Test Trail', - surface: 'paved', - overall_condition: 'fair', - }, - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('401'); - }); - }); -}); diff --git a/packages/mcp/src/__tests__/tools/trips.test.ts b/packages/mcp/src/__tests__/tools/trips.test.ts deleted file mode 100644 index 80ef35ce42..0000000000 --- a/packages/mcp/src/__tests__/tools/trips.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PackRatApiClient } from '../../client'; -import { ApiError } from '../../client'; -import { registerTripTools } from '../../tools/trips'; -import type { RegisteredTool } from '../helpers'; -import { buildMockAgent, callTool, parseToolResult } from '../helpers'; - -describe('trip tools', () => { - let api: PackRatApiClient; - let tools: Map; - - beforeEach(() => { - const mock = buildMockAgent(); - api = mock.api; - tools = mock.tools; - registerTripTools(mock.agent); - }); - - // ── list_trips ────────────────────────────────────────────────────────────── - - describe('list_trips', () => { - it('is registered', () => { - expect(tools.has('list_trips')).toBe(true); - }); - - it('calls GET /trips with includePublic param', async () => { - vi.mocked(api.get).mockResolvedValue({ items: [] }); - - await callTool({ tools, name: 'list_trips', args: { include_public: true } }); - - expect(api.get).toHaveBeenCalledWith('/trips', { includePublic: 1 }); - }); - - it('returns error result on API failure', async () => { - vi.mocked(api.get).mockRejectedValue(new Error('Network error')); - - const result = await callTool({ tools, name: 'list_trips', args: {} }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Network error'); - }); - }); - - // ── get_trip ──────────────────────────────────────────────────────────────── - - describe('get_trip', () => { - it('calls GET /trips/:id', async () => { - const trip = { id: 't_abc', name: 'JMT 2025' }; - vi.mocked(api.get).mockResolvedValue(trip); - - const result = await callTool({ tools, name: 'get_trip', args: { trip_id: 't_abc' } }); - - expect(api.get).toHaveBeenCalledWith('/trips/t_abc'); - expect(parseToolResult(result)).toEqual(trip); - }); - }); - - // ── create_trip ───────────────────────────────────────────────────────────── - - describe('create_trip', () => { - it('calls POST /trips with required fields and generated ID', async () => { - const created = { id: 't_new', name: 'PCT Section J' }; - vi.mocked(api.post).mockResolvedValue(created); - - await callTool({ - tools, - name: 'create_trip', - args: { - name: 'PCT Section J', - description: 'Southern Sierra trip', - start_date: '2025-09-01T00:00:00Z', - end_date: '2025-09-07T00:00:00Z', - }, - }); - - const [path, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(path).toBe('/trips'); - expect(body.name).toBe('PCT Section J'); - expect(body.description).toBe('Southern Sierra trip'); - expect(body.startDate).toBe('2025-09-01T00:00:00Z'); - expect(body.endDate).toBe('2025-09-07T00:00:00Z'); - expect(typeof body.id).toBe('string'); - expect((body.id as string).startsWith('t_')).toBe(true); - }); - - it('builds location object when lat/lng are provided', async () => { - vi.mocked(api.post).mockResolvedValue({ id: 't_1' }); - - await callTool({ - tools, - name: 'create_trip', - args: { - name: 'Yosemite', - latitude: 37.8651, - longitude: -119.5383, - location_name: 'Yosemite Valley', - }, - }); - - const [, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - const loc = body.location as Record; - expect(loc).not.toBeNull(); - expect(loc.latitude).toBe(37.8651); - expect(loc.longitude).toBe(-119.5383); - expect(loc.name).toBe('Yosemite Valley'); - }); - - it('sets location to null when no coordinates or name provided', async () => { - vi.mocked(api.post).mockResolvedValue({ id: 't_2' }); - - await callTool({ tools, name: 'create_trip', args: { name: 'Nameless Trip' } }); - - const [, body] = vi.mocked(api.post).mock.calls[0] as [string, Record]; - expect(body.location).toBeNull(); - }); - }); - - // ── update_trip ───────────────────────────────────────────────────────────── - - describe('update_trip', () => { - it('calls PATCH /trips/:id with provided fields only', async () => { - vi.mocked(api.patch).mockResolvedValue({ id: 't_1' }); - - await callTool({ - tools, - name: 'update_trip', - args: { - trip_id: 't_1', - name: 'Renamed Trip', - notes: 'Bring bear canister', - }, - }); - - const [path, body] = vi.mocked(api.patch).mock.calls[0] as [string, Record]; - expect(path).toBe('/trips/t_1'); - expect(body.name).toBe('Renamed Trip'); - expect(body.notes).toBe('Bring bear canister'); - expect(body.startDate).toBeUndefined(); - }); - - it('sends just name when only location_name is provided (no 0,0 coords)', async () => { - vi.mocked(api.patch).mockResolvedValue({ id: 't_1' }); - - await callTool({ - tools, - name: 'update_trip', - args: { - trip_id: 't_1', - location_name: 'New Location', - }, - }); - - const [, body] = vi.mocked(api.patch).mock.calls[0] as [string, Record]; - const loc = body.location as Record; - expect(loc.name).toBe('New Location'); - expect(loc.latitude).toBeUndefined(); - expect(loc.longitude).toBeUndefined(); - }); - }); - - // ── delete_trip ───────────────────────────────────────────────────────────── - - describe('delete_trip', () => { - it('calls DELETE /trips/:id', async () => { - vi.mocked(api.delete).mockResolvedValue({ deleted: true }); - - const result = await callTool({ tools, name: 'delete_trip', args: { trip_id: 't_del' } }); - - expect(api.delete).toHaveBeenCalledWith('/trips/t_del'); - expect(parseToolResult(result)).toEqual({ deleted: true }); - }); - - it('returns error when API fails', async () => { - vi.mocked(api.delete).mockRejectedValue(new ApiError('Forbidden', { status: 403, body: {} })); - - const result = await callTool({ tools, name: 'delete_trip', args: { trip_id: 't_x' } }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('403'); - }); - }); -}); diff --git a/packages/mcp/src/__tests__/tools/weather.test.ts b/packages/mcp/src/__tests__/tools/weather.test.ts deleted file mode 100644 index b25234cef6..0000000000 --- a/packages/mcp/src/__tests__/tools/weather.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PackRatApiClient } from '../../client'; -import { ApiError } from '../../client'; -import { registerWeatherTools } from '../../tools/weather'; -import type { RegisteredTool } from '../helpers'; -import { buildMockAgent, callTool, parseToolResult } from '../helpers'; - -describe('weather tools', () => { - let api: PackRatApiClient; - let tools: Map; - - beforeEach(() => { - const mock = buildMockAgent(); - api = mock.api; - tools = mock.tools; - registerWeatherTools(mock.agent); - }); - - // ── get_weather ───────────────────────────────────────────────────────────── - - describe('get_weather', () => { - it('is registered', () => { - expect(tools.has('get_weather')).toBe(true); - }); - - it('performs search then forecast (two-step flow)', async () => { - const searchResult = { id: 'loc_123', name: 'Yosemite Valley' }; - const forecast = { location: 'Yosemite', temp: 55, forecast: [] }; - vi.mocked(api.get) - .mockResolvedValueOnce(searchResult) // step 1: search - .mockResolvedValueOnce(forecast); // step 2: forecast - - const result = await callTool({ - tools, - name: 'get_weather', - args: { location: 'Yosemite Valley, CA' }, - }); - - expect(api.get).toHaveBeenCalledTimes(2); - expect(vi.mocked(api.get).mock.calls[0]).toEqual([ - '/weather/search', - { q: 'Yosemite Valley, CA' }, - ]); - expect(vi.mocked(api.get).mock.calls[1]).toEqual(['/weather/forecast', { id: 'loc_123' }]); - expect(parseToolResult(result)).toEqual(forecast); - }); - - it('returns error when location search finds nothing', async () => { - vi.mocked(api.get).mockResolvedValueOnce({}); // no id in response - - const result = await callTool({ - tools, - name: 'get_weather', - args: { location: 'Nowhere Special' }, - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('No weather location found'); - }); - - it('returns error result when search API fails', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Bad Request', { status: 400, body: {} })); - - const result = await callTool({ - tools, - name: 'get_weather', - args: { location: 'Bad Location' }, - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('400'); - }); - }); - - // ── search_weather_location ───────────────────────────────────────────────── - - describe('search_weather_location', () => { - it('is registered', () => { - expect(tools.has('search_weather_location')).toBe(true); - }); - - it('calls GET /weather/search with q param', async () => { - vi.mocked(api.get).mockResolvedValue([{ id: 'loc_1', name: 'Seattle' }]); - - const result = await callTool({ - tools, - name: 'search_weather_location', - args: { query: 'Seattle, WA' }, - }); - - expect(api.get).toHaveBeenCalledWith('/weather/search', { q: 'Seattle, WA' }); - expect(Array.isArray(parseToolResult(result))).toBe(true); - }); - }); - - // ── get_season_suggestions ────────────────────────────────────────────────── - - describe('get_season_suggestions', () => { - it('is registered', () => { - expect(tools.has('get_season_suggestions')).toBe(true); - }); - - it('calls POST /season-suggestions with destination', async () => { - const suggestions = { - destination: 'Patagonia', - seasons: [{ name: 'Summer', months: 'Dec-Feb', conditions: 'best' }], - }; - vi.mocked(api.post).mockResolvedValue(suggestions); - - const result = await callTool({ - tools, - name: 'get_season_suggestions', - args: { destination: 'Patagonia' }, - }); - - expect(api.post).toHaveBeenCalledWith('/season-suggestions', { destination: 'Patagonia' }); - expect(parseToolResult(result)).toEqual(suggestions); - }); - - it('returns error when API fails', async () => { - vi.mocked(api.post).mockRejectedValue(new Error('Timeout')); - - const result = await callTool({ - tools, - name: 'get_season_suggestions', - args: { destination: 'Nowhere' }, - }); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Timeout'); - }); - }); -}); diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 0a3d3d4de8..c1c00a7cd1 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1,17 +1,188 @@ /** - * Re-export the PackRat API client primitives from the shared api-client package. - * MCP tool files import from here to keep their dependencies clean. + * MCP API client layer — Eden Treaty based. + * + * Two typed clients are exposed: + * + * - `user`: authenticated as the OAuth-signed-in PackRat user via the Better + * Auth bearer that OAuthProvider injects into each request. + * - `admin`: authenticated as a PackRat admin via the short-lived admin JWT + * issued by `POST /api/admin/token` (or by passing an env-provided token). + * + * Tool files import these from `agent.api` and call the API with end-to-end + * type safety. The `call()` helper converts Treaty's + * `{ data, error, status }` response shape into MCP tool results and maps + * 401/403 to actionable, ACL-aware error messages. */ -export type { - ApiErrorOptions, - PackRatApiClient as PackRatClient, - QueryParams, -} from '@packrat/api-client'; -export { - ApiError, - createPackRatClient, - err, - ok, - PackRatApiClient, -} from '@packrat/api-client'; +import { type ApiClient, createApiClient } from '@packrat/api-client'; +import { isObject, isString } from '@packrat/guards'; + +export type TokenProvider = () => string | null | undefined; + +export type McpClients = { + /** Calls authenticated as the OAuth-signed-in PackRat user. */ + user: ApiClient; + /** Calls authenticated with a PackRat admin JWT. */ + admin: ApiClient; +}; + +/** + * Build user and admin Eden Treaty clients sharing a single base URL. + * + * The user client uses the Better Auth bearer that the OAuth provider + * (or a manual `Authorization` header) injected into the current request. + * The admin client uses the short-lived admin JWT minted by + * `POST /api/admin/token`. + * + * Refresh/reauth hooks are no-ops here: the MCP transport does not own session + * lifecycle (the OAuth layer / caller does), so on 401 we surface the error + * to the tool rather than attempting a refresh. + */ +export function createMcpClients(opts: { + baseUrl: string; + getUserToken: TokenProvider; + getAdminToken: TokenProvider; +}): McpClients { + return { + user: createApiClient({ baseUrl: opts.baseUrl, auth: noopHooks(opts.getUserToken) }), + admin: createApiClient({ baseUrl: opts.baseUrl, auth: noopHooks(opts.getAdminToken) }), + }; +} + +function noopHooks(getToken: TokenProvider) { + return { + getAccessToken: () => getToken() ?? null, + getRefreshToken: () => null, + onAccessTokenRefreshed: () => {}, + onNeedsReauth: () => {}, + }; +} + +// ── MCP tool result helpers ─────────────────────────────────────────────────── + +export type McpToolResult = { + content: [{ type: 'text'; text: string }]; + isError?: true; +}; + +export function ok(data: unknown): McpToolResult { + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; +} + +export function errMessage(message: string): McpToolResult { + return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true }; +} + +/** + * Treaty response shape used by `call()`. Defined structurally so we don't + * have to import internal Eden types. + */ +export type TreatyResponse = { + data: T | null; + error: { status: number; value: unknown } | null; + status: number; +}; + +export type CallOptions = { + /** Verb-phrase shown in error messages, e.g. "list packs". */ + action?: string; + /** Optional resource hint for ACL errors, e.g. `pack p_abc123`. */ + resourceHint?: string; + /** Marks this call as admin-only; refines 401/403 messaging. */ + requiresAdmin?: boolean; +}; + +/** + * Await a Treaty promise and convert the result into an MCP tool result. + * Thrown errors and `{ error: ... }` responses both surface as `isError: true`. + */ +export async function call( + promise: Promise>, + options: CallOptions = {}, +): Promise { + try { + const result = await promise; + if (result.error || result.data == null) { + return formatError(result.status, result.error?.value, options); + } + return ok(result.data); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return errMessage(`${options.action ?? 'request'} failed: ${message}`); + } +} + +function formatError(status: number, body: unknown, opts: CallOptions): McpToolResult { + const action = opts.action ?? 'request'; + const resource = opts.resourceHint ? ` (${opts.resourceHint})` : ''; + const detail = extractErrorMessage(body); + const suffix = detail ? ` — ${detail}` : ''; + + if (status === 401) { + if (opts.requiresAdmin) { + return errMessage( + `Admin authentication required to ${action}${resource}. Call admin_login first, ` + + `or provide an admin JWT via the X-PackRat-Admin-Token header.${suffix}`, + ); + } + return errMessage( + `Authentication required to ${action}${resource}. Sign in via OAuth or refresh your ` + + `MCP session.${suffix}`, + ); + } + if (status === 403) { + if (opts.requiresAdmin) { + return errMessage( + `Forbidden: this operation is admin-only (${action}${resource}). Your token does not ` + + `carry the admin role.${suffix}`, + ); + } + return errMessage( + `Forbidden: you don't own this resource (${action}${resource}), or the API rejected ` + + `access. Soft-deleted or other-user resources are not visible.${suffix}`, + ); + } + if (status === 404) { + return errMessage(`Not found: ${action}${resource} returned 404.${suffix}`); + } + if (status === 409) { + return errMessage(`Conflict on ${action}${resource}.${suffix}`); + } + if (status === 422) { + return errMessage(`Validation failed on ${action}${resource}.${suffix}`); + } + if (status === 429) { + return errMessage(`Rate limited on ${action}${resource}. Try again shortly.${suffix}`); + } + return errMessage(`${action}${resource} failed (HTTP ${status})${suffix}`); +} + +function extractErrorMessage(body: unknown): string | null { + if (body == null) return null; + if (isString(body)) return body; + if (isObject(body)) { + const obj = body as Record; + if (isString(obj.message)) return obj.message; + if (isString(obj.error)) return obj.error; + try { + return JSON.stringify(body); + } catch { + return null; + } + } + return String(body); +} + +// ── ID helpers (replicated here so tool files don't need their own RNG) ─────── + +const STRIP_HYPHENS_RE = /-/g; + +/** Generate a short ID prefixed for stable client-side creation. */ +export function shortId(prefix: string): string { + return `${prefix}_${crypto.randomUUID().replace(STRIP_HYPHENS_RE, '').slice(0, 12)}`; +} + +/** ISO 8601 timestamp for `localCreatedAt` / `localUpdatedAt` fields. */ +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 0cd3bafc0d..c54843e138 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -4,14 +4,22 @@ * A full-featured Model Context Protocol server for outdoor adventure planning, * built on Cloudflare Workers + Durable Objects using the Cloudflare Agents SDK. * + * The MCP server is intentionally a *lean* layer on top of the PackRat API. + * All business logic lives in the API (`@packrat/api`); this package just + * surfaces typed tool wrappers via Eden Treaty, plus per-session auth state. + * * Features: - * - 20+ tools: packs, gear catalog, trips, weather, trail conditions, outdoor knowledge - * - MCP resources: pack/trip/gear data accessible by URI - * - Guided prompts: trip planning, pack optimization, gear recommendations - * - Stateful sessions with hibernation (via Durable Objects) - * - OAuth 2.1 + PKCE authorization via @cloudflare/workers-oauth-provider + * - 60+ tools across user + admin surfaces — packs, gear catalog, trips, + * weather, trail conditions, outdoor knowledge, feed, pack templates, + * season suggestions, wildlife, alltrails, uploads, guides, AI, admin. + * - End-to-end typed Eden Treaty calls to the PackRat API. + * - MCP resources: pack/trip/gear data accessible by URI. + * - Guided prompts: trip planning, pack optimization, gear recommendations. + * - Stateful sessions with hibernation (via Durable Objects). + * - OAuth 2.1 + PKCE authorization via @cloudflare/workers-oauth-provider. + * - Per-session admin JWT, supplied via `X-PackRat-Admin-Token` or `admin_login`. * - * Transport: Streamable HTTP (default) and SSE + * Transport: Streamable HTTP (default) and SSE. * * OAuth flow: * GET /authorize → login form redirect @@ -22,31 +30,42 @@ */ import { OAuthProvider } from '@cloudflare/workers-oauth-provider'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer, type RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpAgent } from 'agents/mcp'; import { z } from 'zod'; import { PackRatAuthHandler } from './auth'; -import type { PackRatApiClient } from './client'; -import { createPackRatClient } from './client'; +import { createMcpClients, type McpClients } from './client'; import { registerPrompts } from './prompts'; import { registerResources } from './resources'; +import { registerAdminTools } from './tools/admin'; +import { registerAiTools } from './tools/ai'; +import { registerAlltrailsTools } from './tools/alltrails'; +import { registerAuthTools } from './tools/auth'; import { registerCatalogTools } from './tools/catalog'; +import { registerFeedTools } from './tools/feed'; +import { registerGuidesTools } from './tools/guides'; import { registerKnowledgeTools } from './tools/knowledge'; +import { registerPackTemplateTools } from './tools/packTemplates'; import { registerPackTools } from './tools/packs'; +import { registerSeasonTools } from './tools/seasons'; import { registerTrailConditionTools } from './tools/trail-conditions'; import { registerTrailTools } from './tools/trails'; import { registerTripTools } from './tools/trips'; +import { registerUploadTools } from './tools/upload'; +import { registerUserTools } from './tools/user'; import { registerWeatherTools } from './tools/weather'; -import type { Env } from './types'; +import { registerWildlifeTools } from './tools/wildlife'; +import type { AgentContext, Env } from './types'; -// Re-export Env for consumers (e.g. tests) export type { Env }; // ── Session state ───────────────────────────────────────────────────────────── export interface State { - /** Better Auth session token, injected per-request from OAuth props or legacy Bearer header */ + /** Better Auth session token, injected per-request from OAuth props or a Bearer header. */ authToken: string; + /** Admin JWT, populated by `admin_login` or injected via `X-PackRat-Admin-Token`. */ + adminToken: string; } // ── MCP Agent (Durable Object) ──────────────────────────────────────────────── @@ -54,39 +73,131 @@ export interface State { export class PackRatMCP extends McpAgent> { server = new McpServer({ name: 'packrat', - version: '1.0.0', + version: '2.0.0', }); - initialState: State = { authToken: '' }; + initialState: State = { authToken: '', adminToken: '' }; - private _api: PackRatApiClient | null = null; + private _api: McpClients | null = null; + private _adminTools: RegisteredTool[] = []; + private _flaggedTools: Map = new Map(); + private _flagState: Map = new Map(); - get api(): PackRatApiClient { + get api(): McpClients { if (!this._api) { - this._api = createPackRatClient(this.env.PACKRAT_API_URL, () => this.state.authToken); + this._api = createMcpClients({ + baseUrl: this.apiBaseUrl, + getUserToken: () => this.state.authToken, + getAdminToken: () => this.state.adminToken, + }); } return this._api; } + get apiBaseUrl(): string { + return this.env.PACKRAT_API_URL; + } + + /** Replace the per-session admin token. Toggles visibility of admin tools. */ + setAdminToken(token: string): void { + if (token === this.state.adminToken) return; + this.setState({ ...this.state, adminToken: token }); + this.syncAdminToolVisibility(); + } + + /** + * Register a tool that's only listed when an admin JWT is on the session. + * Mirrors `server.registerTool` and toggles visibility via the MCP SDK's + * `enable()/disable()` (which emits `tools/list_changed`). + */ + registerAdminTool: McpServer['registerTool'] = (...args) => { + // safe-cast: McpServer.registerTool's overloads collapse at the implementation level; + // forwarding via spread requires a single call signature here. + const tool = (this.server.registerTool as (...a: unknown[]) => RegisteredTool)(...args); + this._adminTools.push(tool); + if (!this.state.adminToken) tool.disable(); + return tool; + }; + + private syncAdminToolVisibility(): void { + const enabled = Boolean(this.state.adminToken); + for (const tool of this._adminTools) { + if (enabled && !tool.enabled) tool.enable(); + else if (!enabled && tool.enabled) tool.disable(); + } + } + + /** + * Register a tool gated on a feature flag. The tool is hidden unless the + * flag is present in `MCP_FEATURE_FLAGS` or enabled via `setFeatureFlag`. + */ + registerFlaggedTool: AgentContext['registerFlaggedTool'] = (flag, ...args) => { + // safe-cast: McpServer.registerTool's overloads collapse at the implementation level; + // forwarding via spread requires a single call signature here. + const tool = (this.server.registerTool as (...a: unknown[]) => RegisteredTool)(...args); + const bucket = this._flaggedTools.get(flag) ?? []; + bucket.push(tool); + this._flaggedTools.set(flag, bucket); + if (!this.isFlagEnabled(flag)) tool.disable(); + return tool; + }; + + setFeatureFlag(flag: string, enabled: boolean): void { + this._flagState.set(flag, enabled); + for (const tool of this._flaggedTools.get(flag) ?? []) { + if (enabled && !tool.enabled) tool.enable(); + else if (!enabled && tool.enabled) tool.disable(); + } + } + + private isFlagEnabled(flag: string): boolean { + const runtime = this._flagState.get(flag); + if (runtime !== undefined) return runtime; + const envList = (this.env.MCP_FEATURE_FLAGS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return envList.includes(flag); + } + override async fetch(request: Request): Promise { const authHeader = request.headers.get('Authorization'); - const token = authHeader?.match(BEARER_REGEX)?.[1] ?? ''; + const userToken = authHeader?.match(BEARER_REGEX)?.[1] ?? ''; + const adminToken = request.headers.get('X-PackRat-Admin-Token') ?? ''; - if (token !== this.state.authToken) { - this.setState({ ...this.state, authToken: token }); + const nextAuth = userToken || this.state.authToken; + const nextAdmin = adminToken || this.state.adminToken; + if (nextAuth !== this.state.authToken || nextAdmin !== this.state.adminToken) { + this.setState({ ...this.state, authToken: nextAuth, adminToken: nextAdmin }); } return super.fetch(request); } async init(): Promise { + // ── User-level (Bearer) ──────────────────────────────────────────────── + registerAuthTools(this); + registerUserTools(this); registerPackTools(this); + registerPackTemplateTools(this); registerCatalogTools(this); registerTripTools(this); registerWeatherTools(this); registerKnowledgeTools(this); registerTrailConditionTools(this); registerTrailTools(this); + registerFeedTools(this); + registerSeasonTools(this); + registerWildlifeTools(this); + registerAlltrailsTools(this); + registerUploadTools(this); + registerGuidesTools(this); + registerAiTools(this); + + // ── Admin (admin JWT) ────────────────────────────────────────────────── + registerAdminTools(this); + + // ── Resources + prompts ──────────────────────────────────────────────── registerResources(this); registerPrompts(this); } @@ -103,6 +214,7 @@ const mcpDoHandler = PackRatMCP.serve('/mcp'); const PropsSchema = z.object({ betterAuthToken: z.string(), userId: z.string(), + adminToken: z.string().optional(), }); // ── API handler: wraps McpAgent, injecting the Better Auth token from OAuth props ── @@ -111,11 +223,13 @@ const mcpApiHandler = { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const rawCtx = ctx as unknown as Record; // safe-cast: OAuth provider injects props at runtime; ExecutionContext has no index signature const propsResult = PropsSchema.safeParse(rawCtx.props); - const token = propsResult.success ? propsResult.data.betterAuthToken : ''; + const userToken = propsResult.success ? propsResult.data.betterAuthToken : ''; + const adminToken = propsResult.success ? (propsResult.data.adminToken ?? '') : ''; const headers = new Headers(request.headers); - if (token) { - headers.set('Authorization', `Bearer ${token}`); + if (userToken) headers.set('Authorization', `Bearer ${userToken}`); + if (adminToken && !headers.has('X-PackRat-Admin-Token')) { + headers.set('X-PackRat-Admin-Token', adminToken); } return mcpDoHandler.fetch(new Request(request, { headers }), env, ctx); diff --git a/packages/mcp/src/resources.ts b/packages/mcp/src/resources.ts index cdcb093656..67ec5c8cfe 100644 --- a/packages/mcp/src/resources.ts +++ b/packages/mcp/src/resources.ts @@ -1,19 +1,54 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { ApiError } from './client'; import type { AgentContext } from './types'; -function resourceError(opts: { uri: string; context: string; error: unknown }): object { - const { uri, context, error } = opts; - if (error instanceof ApiError) { - return { uri, error: error.message, status: error.status, context }; +type TreatyResult = { + data: unknown; + error: { status: number; value: unknown } | null; + status: number; +}; + +function resourceError(opts: { uri: string; context: string; status: number; value: unknown }) { + const { uri, context, status, value } = opts; + const message = + typeof value === 'string' + ? value + : value && typeof value === 'object' && 'error' in value + ? String((value as { error: unknown }).error) + : `HTTP ${status}`; + return { uri, context, status, error: message }; +} + +function asContent(uri: string, body: object): { contents: Array<{ uri: string; mimeType: string; text: string }> } { + return { + contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(body, null, 2) }], + }; +} + +async function settle( + uri: string, + context: string, + promise: Promise, +): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> { + try { + const { data, error, status } = await promise; + if (error || data == null) { + return asContent( + uri, + resourceError({ uri, context, status, value: error?.value ?? null }), + ); + } + return asContent(uri, data as object); + } catch (e) { + return asContent(uri, { + uri, + context, + error: e instanceof Error ? e.message : String(e), + }); } - return { uri, error: error instanceof Error ? error.message : String(error), context }; } export function registerResources(agent: AgentContext): void { - // ── Pack resource (URI template) ────────────────────────────────────────── - // Clients can read: packrat://packs/ - + // ── Pack resource ───────────────────────────────────────────────────────── agent.server.registerResource( 'pack', new ResourceTemplate('packrat://packs/{packId}', { list: undefined }), @@ -22,32 +57,11 @@ export function registerResources(agent: AgentContext): void { 'A PackRat packing list. Contains all items with weights, categories, and computed weight totals.', mimeType: 'application/json', }, - async (uri, { packId }) => { - try { - const pack = await agent.api.get(`/packs/${String(packId)}`); - return { - contents: [ - { uri: uri.href, mimeType: 'application/json', text: JSON.stringify(pack, null, 2) }, - ], - }; - } catch (e) { - return { - contents: [ - { - uri: uri.href, - mimeType: 'application/json', - text: JSON.stringify( - resourceError({ uri: uri.href, context: `pack:${String(packId)}`, error: e }), - ), - }, - ], - }; - } - }, + (uri, { packId }) => + settle(uri.href, `pack:${String(packId)}`, agent.api.user.packs({ packId: String(packId) }).get()), ); // ── Trip resource ───────────────────────────────────────────────────────── - agent.server.registerResource( 'trip', new ResourceTemplate('packrat://trips/{tripId}', { list: undefined }), @@ -56,32 +70,11 @@ export function registerResources(agent: AgentContext): void { 'A PackRat trip plan. Contains destination, dates, notes, and linked pack information.', mimeType: 'application/json', }, - async (uri, { tripId }) => { - try { - const trip = await agent.api.get(`/trips/${String(tripId)}`); - return { - contents: [ - { uri: uri.href, mimeType: 'application/json', text: JSON.stringify(trip, null, 2) }, - ], - }; - } catch (e) { - return { - contents: [ - { - uri: uri.href, - mimeType: 'application/json', - text: JSON.stringify( - resourceError({ uri: uri.href, context: `trip:${String(tripId)}`, error: e }), - ), - }, - ], - }; - } - }, + (uri, { tripId }) => + settle(uri.href, `trip:${String(tripId)}`, agent.api.user.trips({ tripId: String(tripId) }).get()), ); // ── Catalog item resource ───────────────────────────────────────────────── - agent.server.registerResource( 'catalog_item', new ResourceTemplate('packrat://catalog/{itemId}', { list: undefined }), @@ -90,32 +83,15 @@ export function registerResources(agent: AgentContext): void { 'A gear catalog item with full specifications, weight, price, availability, and user reviews.', mimeType: 'application/json', }, - async (uri, { itemId }) => { - try { - const item = await agent.api.get(`/catalog/${String(itemId)}`); - return { - contents: [ - { uri: uri.href, mimeType: 'application/json', text: JSON.stringify(item, null, 2) }, - ], - }; - } catch (e) { - return { - contents: [ - { - uri: uri.href, - mimeType: 'application/json', - text: JSON.stringify( - resourceError({ uri: uri.href, context: `catalog:${String(itemId)}`, error: e }), - ), - }, - ], - }; - } - }, + (uri, { itemId }) => + settle( + uri.href, + `catalog:${String(itemId)}`, + agent.api.user.catalog({ id: String(itemId) }).get(), + ), ); // ── Gear categories list (static URI) ───────────────────────────────────── - agent.server.registerResource( 'gear_categories', 'packrat://catalog/categories', @@ -124,31 +100,6 @@ export function registerResources(agent: AgentContext): void { 'Complete list of gear categories available in the PackRat catalog. Use this to discover what types of gear are available.', mimeType: 'application/json', }, - async (uri) => { - try { - const categories = await agent.api.get('/catalog/categories'); - return { - contents: [ - { - uri: uri.href, - mimeType: 'application/json', - text: JSON.stringify(categories, null, 2), - }, - ], - }; - } catch (e) { - return { - contents: [ - { - uri: uri.href, - mimeType: 'application/json', - text: JSON.stringify( - resourceError({ uri: uri.href, context: 'gear_categories', error: e }), - ), - }, - ], - }; - } - }, + (uri) => settle(uri.href, 'gear_categories', agent.api.user.catalog.categories.get()), ); } diff --git a/packages/mcp/src/tools/admin.ts b/packages/mcp/src/tools/admin.ts new file mode 100644 index 0000000000..1af00a1afe --- /dev/null +++ b/packages/mcp/src/tools/admin.ts @@ -0,0 +1,425 @@ +/** + * Admin tools. + * + * All tools here use the admin Treaty client (`agent.api.admin`) which sends + * the admin JWT minted by `admin_login` (or supplied via `X-PackRat-Admin-Token`). + * Errors with status 401/403 are surfaced with `requiresAdmin: true` so the + * caller gets a clear message about needing to authenticate as admin. + */ + +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +const ADMIN = { requiresAdmin: true as const }; + +export function registerAdminTools(agent: AgentContext): void { + // ── Stats / users / packs / catalog ─────────────────────────────────────── + + agent.registerAdminTool( + 'admin_stats', + { + description: 'Get high-level platform stats: user, pack, and catalog counts.', + inputSchema: {}, + }, + async () => call(agent.api.admin.admin.stats.get(), { action: 'fetch admin stats', ...ADMIN }), + ); + + agent.registerAdminTool( + 'admin_list_users', + { + description: 'Search/list users (paginated). Use `q` to filter by email or name.', + inputSchema: { + q: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), + }, + }, + async ({ q, limit, offset }) => + call(agent.api.admin.admin['users-list'].get({ query: { q, limit, offset } }), { + action: 'list users', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_hard_delete_user', + { + description: + 'GDPR-style hard-delete of a user. Irrevocable. Requires a non-empty `reason` for the audit log.', + inputSchema: { user_id: z.string(), reason: z.string().min(1) }, + }, + async ({ user_id, reason }) => + call(agent.api.admin.admin.users({ id: user_id }).hard.delete({ reason }), { + action: 'hard-delete user', + resourceHint: `user ${user_id}`, + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_list_packs', + { + description: 'Search/list packs across all users (admin view).', + inputSchema: { + q: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), + include_deleted: z.boolean().default(false), + }, + }, + async ({ q, limit, offset, include_deleted }) => + call( + agent.api.admin.admin['packs-list'].get({ + query: { q, limit, offset, includeDeleted: include_deleted ? 1 : 0 }, + }), + { action: 'list packs (admin)', ...ADMIN }, + ), + ); + + agent.registerAdminTool( + 'admin_delete_pack', + { + description: 'Soft-delete a pack as admin (bypasses ownership).', + inputSchema: { pack_id: z.string() }, + }, + async ({ pack_id }) => + call(agent.api.admin.admin.packs({ id: pack_id }).delete(), { + action: 'admin delete pack', + resourceHint: `pack ${pack_id}`, + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_list_catalog', + { + description: 'Search/list catalog items across the platform.', + inputSchema: { + q: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), + }, + }, + async ({ q, limit, offset }) => + call(agent.api.admin.admin['catalog-list'].get({ query: { q, limit, offset } }), { + action: 'list catalog (admin)', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_update_catalog_item', + { + description: 'Update a catalog item (name, brand, price, weight, etc.) as admin.', + inputSchema: { + item_id: z.union([z.string(), z.number()]), + name: z.string().optional(), + brand: z.string().optional(), + categories: z.array(z.string()).optional(), + weight: z.number().min(0).optional(), + weight_unit: z.enum(['g', 'oz', 'kg', 'lb']).optional(), + price: z.number().min(0).optional(), + description: z.string().optional(), + }, + }, + async ({ item_id, name, brand, categories, weight, weight_unit, price, description }) => { + const body: Record = {}; + if (name !== undefined) body.name = name; + if (brand !== undefined) body.brand = brand; + if (categories !== undefined) body.categories = categories; + if (weight !== undefined) body.weight = weight; + if (weight_unit !== undefined) body.weightUnit = weight_unit; + if (price !== undefined) body.price = price; + if (description !== undefined) body.description = description; + return call(agent.api.admin.admin.catalog({ id: String(item_id) }).patch(body), { + action: 'admin update catalog item', + resourceHint: `catalog item ${item_id}`, + ...ADMIN, + }); + }, + ); + + agent.registerAdminTool( + 'admin_delete_catalog_item', + { + description: 'Delete a catalog item as admin.', + inputSchema: { item_id: z.union([z.string(), z.number()]) }, + }, + async ({ item_id }) => + call(agent.api.admin.admin.catalog({ id: String(item_id) }).delete(), { + action: 'admin delete catalog item', + resourceHint: `catalog item ${item_id}`, + ...ADMIN, + }), + ); + + // ── Trails (admin) ──────────────────────────────────────────────────────── + + agent.registerAdminTool( + 'admin_search_trails', + { + description: 'Search OSM trails by name/sport (admin view).', + inputSchema: { + q: z.string().min(1), + sport: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), + }, + }, + async ({ q, sport, limit, offset }) => + call( + agent.api.admin.admin.trails.search.get({ query: { q, sport, limit, offset } }), + { action: 'admin search trails', ...ADMIN }, + ), + ); + + agent.registerAdminTool( + 'admin_get_trail', + { + description: 'Get a trail by OSM relation ID (admin).', + inputSchema: { osm_id: z.string() }, + }, + async ({ osm_id }) => + call(agent.api.admin.admin.trails({ osmId: osm_id }).get(), { + action: 'admin get trail', + resourceHint: `trail ${osm_id}`, + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_get_trail_geometry', + { + description: 'Get full GeoJSON geometry for a trail (admin).', + inputSchema: { osm_id: z.string() }, + }, + async ({ osm_id }) => + call(agent.api.admin.admin.trails({ osmId: osm_id }).geometry.get(), { + action: 'admin get trail geometry', + resourceHint: `trail ${osm_id}`, + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_list_trail_condition_reports', + { + description: 'List trail condition reports across all users (admin).', + inputSchema: { + q: z.string().optional(), + limit: z.number().int().min(1).max(200).default(50), + offset: z.number().int().min(0).default(0), + include_deleted: z.boolean().default(false), + }, + }, + async ({ q, limit, offset, include_deleted }) => + call( + agent.api.admin.admin.trails.conditions.get({ + query: { q, limit, offset, includeDeleted: include_deleted ? 1 : 0 }, + }), + { action: 'list trail condition reports (admin)', ...ADMIN }, + ), + ); + + agent.registerAdminTool( + 'admin_delete_trail_condition_report', + { + description: 'Soft-delete a trail condition report as admin.', + inputSchema: { report_id: z.string() }, + }, + async ({ report_id }) => + call( + agent.api.admin.admin.trails.conditions({ reportId: report_id }).delete(), + { + action: 'admin delete trail report', + resourceHint: `report ${report_id}`, + ...ADMIN, + }, + ), + ); + + // ── Analytics: platform ─────────────────────────────────────────────────── + + agent.registerAdminTool( + 'admin_analytics_growth', + { + description: 'Platform user/pack growth metrics.', + inputSchema: { + period: z.enum(['day', 'week', 'month']).optional(), + range: z.number().int().min(1).optional(), + }, + }, + async ({ period, range }) => + call( + agent.api.admin.admin.analytics.platform.growth.get({ query: { period, range } }), + { action: 'admin analytics growth', ...ADMIN }, + ), + ); + + agent.registerAdminTool( + 'admin_analytics_activity', + { + description: 'Platform activity metrics over a time period.', + inputSchema: { + period: z.enum(['day', 'week', 'month']).optional(), + range: z.number().int().min(1).optional(), + }, + }, + async ({ period, range }) => + call( + agent.api.admin.admin.analytics.platform.activity.get({ query: { period, range } }), + { action: 'admin analytics activity', ...ADMIN }, + ), + ); + + agent.registerAdminTool( + 'admin_analytics_active_users', + { + description: 'Daily/weekly/monthly active user counts.', + inputSchema: {}, + }, + async () => + call(agent.api.admin.admin.analytics.platform['active-users'].get(), { + action: 'admin analytics active users', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_analytics_pack_breakdown', + { + description: 'Distribution of packs by category.', + inputSchema: {}, + }, + async () => + call(agent.api.admin.admin.analytics.platform.breakdown.get(), { + action: 'admin analytics breakdown', + ...ADMIN, + }), + ); + + // ── Analytics: catalog ──────────────────────────────────────────────────── + + agent.registerAdminTool( + 'admin_analytics_catalog_overview', + { + description: 'Catalog-wide overview: item count, brands, price ranges, embedding coverage.', + inputSchema: {}, + }, + async () => + call(agent.api.admin.admin.analytics.catalog.overview.get(), { + action: 'admin catalog overview', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_analytics_top_brands', + { + description: 'Top gear brands in the catalog by item count.', + inputSchema: { limit: z.number().int().min(1).max(200).default(20) }, + }, + async ({ limit }) => + call(agent.api.admin.admin.analytics.catalog.brands.get({ query: { limit } }), { + action: 'admin catalog brands', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_analytics_catalog_prices', + { + description: 'Price distribution across the catalog.', + inputSchema: {}, + }, + async () => + call(agent.api.admin.admin.analytics.catalog.prices.get(), { + action: 'admin catalog prices', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_analytics_catalog_embeddings', + { + description: 'Catalog embedding coverage stats.', + inputSchema: {}, + }, + async () => + call(agent.api.admin.admin.analytics.catalog.embeddings.get(), { + action: 'admin catalog embedding stats', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_analytics_etl_jobs', + { + description: 'Recent ETL pipeline jobs.', + inputSchema: { limit: z.number().int().min(1).max(200).default(20) }, + }, + async ({ limit }) => + call(agent.api.admin.admin.analytics.catalog.etl.get({ query: { limit } }), { + action: 'admin ETL jobs', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_analytics_etl_failure_summary', + { + description: 'Top recent ETL failure patterns.', + inputSchema: { limit: z.number().int().min(1).max(50).default(10) }, + }, + async ({ limit }) => + call( + agent.api.admin.admin.analytics.catalog.etl['failure-summary'].get({ query: { limit } }), + { action: 'admin ETL failure summary', ...ADMIN }, + ), + ); + + agent.registerAdminTool( + 'admin_analytics_etl_job_failures', + { + description: 'Per-job ETL failure drill-down.', + inputSchema: { + job_id: z.string(), + limit: z.number().int().min(1).max(200).default(50), + }, + }, + async ({ job_id, limit }) => + call( + agent.api.admin.admin.analytics.catalog.etl({ jobId: job_id }).failures.get({ + query: { limit }, + }), + { action: 'admin ETL job failures', resourceHint: `job ${job_id}`, ...ADMIN }, + ), + ); + + agent.registerAdminTool( + 'admin_etl_reset_stuck', + { + description: 'Mark stuck-running ETL jobs as failed (admin maintenance).', + inputSchema: {}, + }, + async () => + call(agent.api.admin.admin.analytics.catalog.etl['reset-stuck'].post({}), { + action: 'admin ETL reset stuck', + ...ADMIN, + }), + ); + + agent.registerAdminTool( + 'admin_etl_retry_job', + { + description: 'Retry a specific failed ETL job.', + inputSchema: { job_id: z.string() }, + }, + async ({ job_id }) => + call( + agent.api.admin.admin.analytics.catalog.etl({ jobId: job_id }).retry.post({}), + { action: 'admin ETL retry job', resourceHint: `job ${job_id}`, ...ADMIN }, + ), + ); +} diff --git a/packages/mcp/src/tools/ai.ts b/packages/mcp/src/tools/ai.ts new file mode 100644 index 0000000000..4e2f6b65b9 --- /dev/null +++ b/packages/mcp/src/tools/ai.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +export function registerAiTools(agent: AgentContext): void { + // ── Web search (Perplexity) ─────────────────────────────────────────────── + + agent.server.registerTool( + 'web_search', + { + description: + 'Search the web for current, real-time information using Perplexity AI. Use this for current trail conditions, recent news, current gear prices and deals, permit availability, or anything requiring up-to-date info not in the PackRat knowledge base.', + inputSchema: { query: z.string().min(3) }, + }, + async ({ query }) => + call(agent.api.user.ai['web-search'].get({ query: { q: query } }), { + action: 'web search', + }), + ); + + // ── Execute SQL (read-only) ─────────────────────────────────────────────── + + agent.server.registerTool( + 'execute_sql_query', + { + description: + 'Execute a read-only SQL SELECT query against the PackRat database for advanced analytics. Only SELECT statements are allowed.', + inputSchema: { + query: z.string().min(10), + limit: z.number().int().min(1).max(500).default(100), + }, + }, + async ({ query, limit }) => + call(agent.api.user.ai['execute-sql'].post({ query, limit }), { + action: 'execute SQL', + }), + ); + + // ── DB schema ───────────────────────────────────────────────────────────── + + agent.server.registerTool( + 'get_database_schema', + { + description: 'Get the PackRat DB schema — table names, columns, types.', + inputSchema: {}, + }, + async () => + call(agent.api.user.ai['db-schema'].get(), { action: 'fetch DB schema' }), + ); +} diff --git a/packages/mcp/src/tools/alltrails.ts b/packages/mcp/src/tools/alltrails.ts new file mode 100644 index 0000000000..9ab801c0b3 --- /dev/null +++ b/packages/mcp/src/tools/alltrails.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +export function registerAlltrailsTools(agent: AgentContext): void { + agent.server.registerTool( + 'preview_alltrails_url', + { + description: + 'Fetch trail metadata (title, description, image) from an AllTrails URL using OpenGraph tags.', + inputSchema: { url: z.string().url() }, + }, + async ({ url }) => + call(agent.api.user.alltrails.preview.post({ url }), { + action: 'preview AllTrails URL', + resourceHint: url, + }), + ); +} diff --git a/packages/mcp/src/tools/auth.ts b/packages/mcp/src/tools/auth.ts new file mode 100644 index 0000000000..ea4642a272 --- /dev/null +++ b/packages/mcp/src/tools/auth.ts @@ -0,0 +1,80 @@ +/** + * Auth tools. + * + * The MCP transport authenticates the user via OAuth 2.1, so MCP doesn't need + * to implement email/password login itself. These tools expose the parts of + * the auth surface a model may want to call: + * + * - `whoami` — return the signed-in user profile. + * - `admin_login` — exchange Basic credentials for a short-lived admin JWT + * and store it on the session so admin tools can use it. + * - `admin_logout` — clear the stored admin JWT. + */ + +import { z } from 'zod'; +import { call, errMessage, ok } from '../client'; +import type { AgentContext } from '../types'; + +export function registerAuthTools(agent: AgentContext): void { + // ── Whoami ──────────────────────────────────────────────────────────────── + + agent.server.registerTool( + 'whoami', + { + description: 'Return the currently authenticated PackRat user profile.', + inputSchema: {}, + }, + async () => call(agent.api.user.user.profile.get(), { action: 'fetch profile' }), + ); + + // ── Admin login ─────────────────────────────────────────────────────────── + // POST /api/admin/token uses HTTP Basic auth — hit it via fetch rather than + // Treaty so we can attach the Basic header without disturbing the admin + // Treaty client's Bearer header. + + agent.server.registerTool( + 'admin_login', + { + description: + 'Exchange admin Basic credentials (username + password) for a short-lived admin JWT and store it for the current MCP session. Required before calling any admin_* tool unless an admin JWT was already supplied via the X-PackRat-Admin-Token header.', + inputSchema: { + username: z.string().min(1), + password: z.string().min(1), + }, + }, + async ({ username, password }) => { + const basic = btoa(`${username}:${password}`); + const response = await fetch(`${agent.apiBaseUrl}/api/admin/token`, { + method: 'POST', + headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/json' }, + body: '{}', + }); + const body = (await response.json().catch(() => null)) as { + token?: string; + expiresIn?: number; + error?: string; + } | null; + if (!response.ok || !body?.token) { + return errMessage( + `Admin login failed (HTTP ${response.status})${body?.error ? `: ${body.error}` : ''}`, + ); + } + agent.setAdminToken(body.token); + return ok({ ok: true, expiresIn: body.expiresIn }); + }, + ); + + // ── Admin logout / clear token ──────────────────────────────────────────── + + agent.server.registerTool( + 'admin_logout', + { + description: 'Clear the stored admin JWT for this MCP session.', + inputSchema: {}, + }, + async () => { + agent.setAdminToken(''); + return ok({ ok: true }); + }, + ); +} diff --git a/packages/mcp/src/tools/catalog.ts b/packages/mcp/src/tools/catalog.ts index 003daefe1e..1913a62a6f 100644 --- a/packages/mcp/src/tools/catalog.ts +++ b/packages/mcp/src/tools/catalog.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { err, ok } from '../client'; +import { call } from '../client'; import { CatalogSortField, SortOrder } from '../enums'; import type { AgentContext } from '../types'; @@ -22,33 +22,26 @@ export function registerCatalogTools(agent: AgentContext): void { .describe( 'Filter by category (e.g. "sleeping bags", "tents", "backpacks", "footwear", "apparel")', ), - limit: z - .number() - .int() - .min(1) - .max(50) - .default(10) - .describe('Number of results to return (default 10)'), - page: z.number().int().min(1).default(1).describe('Page number (default 1)'), - sort_by: z.nativeEnum(CatalogSortField).optional().describe('Sort field'), - sort_order: z.nativeEnum(SortOrder).default(SortOrder.Asc).describe('Sort direction'), + limit: z.number().int().min(1).max(50).default(10), + page: z.number().int().min(1).default(1), + sort_by: z.nativeEnum(CatalogSortField).optional(), + sort_order: z.nativeEnum(SortOrder).default(SortOrder.Asc), }, }, - async ({ query, category, limit, page, sort_by, sort_order }) => { - try { - const data = await agent.api.get('/catalog', { - q: query, - category, - limit, - page, - 'sort[field]': sort_by, - 'sort[order]': sort_order, - }); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ query, category, limit, page, sort_by, sort_order }) => + call( + agent.api.user.catalog.get({ + query: { + q: query, + category, + limit, + page, + 'sort[field]': sort_by, + 'sort[order]': sort_order, + }, + }), + { action: 'search catalog' }, + ), ); // ── Semantic/vector search ──────────────────────────────────────────────── @@ -57,31 +50,16 @@ export function registerCatalogTools(agent: AgentContext): void { 'semantic_gear_search', { description: - 'Search the gear catalog using AI-powered semantic/vector search. Unlike keyword search, this understands context and meaning — great for queries like "warm but lightweight insulation layer for cold shoulder-season camping" or "minimalist trail running shoe for rocky terrain".', + 'Search the gear catalog using AI-powered semantic/vector search. Great for natural-language queries like "warm but lightweight insulation layer for cold shoulder-season camping" or "minimalist trail running shoe for rocky terrain".', inputSchema: { - query: z - .string() - .min(3) - .describe( - 'Natural language description of the gear you need. Be specific about use-case, conditions, weight preferences, or features.', - ), - limit: z - .number() - .int() - .min(1) - .max(30) - .default(8) - .describe('Number of results to return (default 8)'), + query: z.string().min(3), + limit: z.number().int().min(1).max(30).default(8), }, }, - async ({ query, limit }) => { - try { - const data = await agent.api.get('/catalog/vector-search', { q: query, limit }); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ query, limit }) => + call(agent.api.user.catalog['vector-search'].get({ query: { q: query, limit } }), { + action: 'semantic catalog search', + }), ); // ── Get single item ─────────────────────────────────────────────────────── @@ -92,20 +70,35 @@ export function registerCatalogTools(agent: AgentContext): void { description: 'Retrieve full details for a specific gear catalog item by ID. Returns all specs, dimensions, weight, price, availability, user reviews, Q&A, and product URL.', inputSchema: { - item_id: z - .number() - .int() - .describe('The catalog item ID (from search_gear_catalog or semantic_gear_search)'), + item_id: z.number().int().describe('The catalog item ID'), }, }, - async ({ item_id }) => { - try { - const data = await agent.api.get(`/catalog/${item_id}`); - return ok(data); - } catch (e) { - return err(e); - } + async ({ item_id }) => + call(agent.api.user.catalog({ id: String(item_id) }).get(), { + action: 'get catalog item', + resourceHint: `catalog item ${item_id}`, + }), + ); + + // ── Similar catalog items ───────────────────────────────────────────────── + + agent.server.registerTool( + 'similar_catalog_items', + { + description: 'Find items similar to a given catalog item by embedding similarity.', + inputSchema: { + item_id: z.number().int(), + limit: z.number().int().min(1).max(50).default(10), + threshold: z.number().min(0).max(1).optional(), + }, }, + async ({ item_id, limit, threshold }) => + call( + agent.api.user.catalog({ id: String(item_id) }).similar.get({ + query: { limit, ...(threshold !== undefined ? { threshold } : {}) }, + }), + { action: 'find similar catalog items', resourceHint: `catalog item ${item_id}` }, + ), ); // ── List categories ─────────────────────────────────────────────────────── @@ -115,39 +108,94 @@ export function registerCatalogTools(agent: AgentContext): void { { description: 'List all available gear categories in the catalog with item counts. Use this to explore what gear types are available before searching.', - inputSchema: {}, + inputSchema: { limit: z.number().int().min(1).max(200).optional() }, }, - async () => { - try { - const data = await agent.api.get('/catalog/categories'); - return ok(data); - } catch (e) { - return err(e); - } + async ({ limit }) => + call(agent.api.user.catalog.categories.get({ query: { limit } }), { + action: 'list catalog categories', + }), + ); + + // ── Create a catalog item (user-submitted) ──────────────────────────────── + + agent.server.registerTool( + 'create_catalog_item', + { + description: + 'Submit a new gear item to the catalog. The API will embed and dedupe automatically. Use this for custom items not yet in the catalog.', + inputSchema: { + name: z.string().min(1), + description: z.string().optional(), + brand: z.string().optional(), + model: z.string().optional(), + weight: z.number().min(0).optional(), + weight_unit: z.enum(['g', 'oz', 'kg', 'lb']).optional(), + categories: z.array(z.string()).optional(), + images: z.array(z.string()).optional(), + rating: z.number().min(0).max(5).optional(), + product_url: z.string().url().optional(), + }, }, + async ({ + name, + description, + brand, + model, + weight, + weight_unit, + categories, + images, + rating, + product_url, + }) => + call( + agent.api.user.catalog.post({ + name, + description, + brand, + model, + weight, + weightUnit: weight_unit, + categories, + images, + rating, + productUrl: product_url, + }), + { action: 'create catalog item' }, + ), ); - // ── Compare items ───────────────────────────────────────────────────────── + // ── Compare items (API-side path proposed; until then, multi-fetch) ─────── + // NOTE: this duplicates work the API could do in a single `/catalog/compare` + // endpoint that accepts an `ids[]` query. Tracked in the API thickening list. agent.server.registerTool( 'compare_gear_items', { description: - 'Compare multiple gear items side-by-side on key attributes: weight, price, rating, and specs. Returns a structured comparison table. Provide 2–5 item IDs.', + 'Compare multiple gear items side-by-side on weight, price, and rating. Provide 2–5 catalog item IDs.', inputSchema: { - item_ids: z - .array(z.number().int()) - .min(2) - .max(5) - .describe('Array of 2–5 catalog item IDs to compare'), + item_ids: z.array(z.number().int()).min(2).max(5), }, }, async ({ item_ids }) => { - try { - const items = await Promise.all( - item_ids.map((id) => agent.api.get>(`/catalog/${id}`)), + const responses = await Promise.all( + item_ids.map((id) => agent.api.user.catalog({ id: String(id) }).get()), + ); + const firstError = responses.find((r) => r.error || !r.data); + if (firstError) { + return call( + Promise.resolve({ + data: null, + error: firstError.error, + status: firstError.status, + }), + { action: 'compare catalog items' }, ); - const comparison = items.map((it) => ({ + } + const comparison = responses.map((r) => { + const it = (r.data ?? {}) as Record; + return { id: it.id, name: it.name, brand: it.brand, @@ -157,25 +205,28 @@ export function registerCatalogTools(agent: AgentContext): void { rating: it.ratingValue, reviewCount: it.ratingCount, productUrl: it.productUrl, - })); - - comparison.sort( - (a, b) => (Number(a.weightGrams) || 999999) - (Number(b.weightGrams) || 999999), - ); - - return ok({ - items: comparison, - lightest: comparison[0]?.name, - cheapest: [...comparison].sort( - (a, b) => (Number(a.priceCents) || 999999) - (Number(b.priceCents) || 999999), - )[0]?.name, - highestRated: [...comparison].sort( - (a, b) => (Number(b.rating) || 0) - (Number(a.rating) || 0), - )[0]?.name, - }); - } catch (e) { - return err(e); - } + }; + }); + comparison.sort( + (a, b) => (Number(a.weightGrams) || 999_999) - (Number(b.weightGrams) || 999_999), + ); + return call( + Promise.resolve({ + data: { + items: comparison, + lightest: comparison[0]?.name, + cheapest: [...comparison].sort( + (a, b) => (Number(a.priceCents) || 999_999) - (Number(b.priceCents) || 999_999), + )[0]?.name, + highestRated: [...comparison].sort( + (a, b) => (Number(b.rating) || 0) - (Number(a.rating) || 0), + )[0]?.name, + }, + error: null, + status: 200, + }), + { action: 'compare catalog items' }, + ); }, ); } diff --git a/packages/mcp/src/tools/feed.ts b/packages/mcp/src/tools/feed.ts new file mode 100644 index 0000000000..21acd7ebc1 --- /dev/null +++ b/packages/mcp/src/tools/feed.ts @@ -0,0 +1,145 @@ +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +export function registerFeedTools(agent: AgentContext): void { + // ── Posts ───────────────────────────────────────────────────────────────── + + agent.server.registerTool( + 'list_feed', + { + description: 'List social feed posts (paginated).', + inputSchema: { + page: z.number().int().min(1).default(1), + limit: z.number().int().min(1).max(50).default(20), + }, + }, + async ({ page, limit }) => + call(agent.api.user.feed.get({ query: { page, limit } }), { action: 'list feed' }), + ); + + agent.server.registerTool( + 'create_feed_post', + { + description: 'Create a feed post with a caption and optional image keys.', + inputSchema: { + caption: z.string().min(1), + images: z.array(z.string()).optional(), + }, + }, + async ({ caption, images }) => + call(agent.api.user.feed.post({ caption, images: images ?? [] }), { + action: 'create feed post', + }), + ); + + agent.server.registerTool( + 'get_feed_post', + { + description: 'Get a specific feed post by ID.', + inputSchema: { post_id: z.string() }, + }, + async ({ post_id }) => + call(agent.api.user.feed({ postId: post_id }).get(), { + action: 'get feed post', + resourceHint: `post ${post_id}`, + }), + ); + + agent.server.registerTool( + 'delete_feed_post', + { + description: 'Delete one of your own feed posts.', + inputSchema: { post_id: z.string() }, + }, + async ({ post_id }) => + call(agent.api.user.feed({ postId: post_id }).delete(), { + action: 'delete feed post', + resourceHint: `post ${post_id}`, + }), + ); + + agent.server.registerTool( + 'toggle_feed_post_like', + { + description: 'Like or unlike a feed post (toggle).', + inputSchema: { post_id: z.string() }, + }, + async ({ post_id }) => + call(agent.api.user.feed({ postId: post_id }).like.post({}), { + action: 'toggle feed post like', + resourceHint: `post ${post_id}`, + }), + ); + + // ── Comments ────────────────────────────────────────────────────────────── + + agent.server.registerTool( + 'list_feed_comments', + { + description: 'List comments on a feed post.', + inputSchema: { + post_id: z.string(), + page: z.number().int().min(1).default(1), + limit: z.number().int().min(1).max(100).default(20), + }, + }, + async ({ post_id, page, limit }) => + call(agent.api.user.feed({ postId: post_id }).comments.get({ query: { page, limit } }), { + action: 'list feed comments', + resourceHint: `post ${post_id}`, + }), + ); + + agent.server.registerTool( + 'create_feed_comment', + { + description: 'Add a comment to a feed post (or reply to a parent comment).', + inputSchema: { + post_id: z.string(), + content: z.string().min(1), + parent_comment_id: z.string().optional(), + }, + }, + async ({ post_id, content, parent_comment_id }) => + call( + agent.api.user.feed({ postId: post_id }).comments.post({ + content, + parentCommentId: parent_comment_id, + }), + { action: 'create feed comment', resourceHint: `post ${post_id}` }, + ), + ); + + agent.server.registerTool( + 'delete_feed_comment', + { + description: 'Delete one of your own feed comments.', + inputSchema: { post_id: z.string(), comment_id: z.string() }, + }, + async ({ post_id, comment_id }) => + call( + agent.api.user + .feed({ postId: post_id }) + .comments({ commentId: comment_id }) + .delete(), + { action: 'delete feed comment', resourceHint: `comment ${comment_id}` }, + ), + ); + + agent.server.registerTool( + 'toggle_feed_comment_like', + { + description: 'Like or unlike a feed comment (toggle).', + inputSchema: { post_id: z.string(), comment_id: z.string() }, + }, + async ({ post_id, comment_id }) => + call( + agent.api.user + .feed({ postId: post_id }) + .comments({ commentId: comment_id }) + .like.post({}), + { action: 'toggle feed comment like', resourceHint: `comment ${comment_id}` }, + ), + ); +} diff --git a/packages/mcp/src/tools/guides.ts b/packages/mcp/src/tools/guides.ts new file mode 100644 index 0000000000..151da3e795 --- /dev/null +++ b/packages/mcp/src/tools/guides.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { call } from '../client'; +import { SortOrder } from '../enums'; +import type { AgentContext } from '../types'; + +export function registerGuidesTools(agent: AgentContext): void { + agent.server.registerTool( + 'list_guides', + { + description: 'List PackRat outdoor guides (paginated, filterable by category).', + inputSchema: { + page: z.number().int().min(1).default(1), + limit: z.number().int().min(1).max(50).default(20), + category: z.string().optional(), + sort_field: z.string().optional(), + sort_order: z.nativeEnum(SortOrder).optional(), + }, + }, + async ({ page, limit, category, sort_field, sort_order }) => + call( + agent.api.user.guides.get({ + query: { + page, + limit, + category, + 'sort[field]': sort_field, + 'sort[order]': sort_order, + }, + }), + { action: 'list guides' }, + ), + ); + + agent.server.registerTool( + 'list_guide_categories', + { + description: 'List all guide categories.', + inputSchema: {}, + }, + async () => call(agent.api.user.guides.categories.get(), { action: 'list guide categories' }), + ); + + agent.server.registerTool( + 'search_guides', + { + description: 'Full-text search across PackRat outdoor guides.', + inputSchema: { + query: z.string().min(2), + page: z.number().int().min(1).default(1), + limit: z.number().int().min(1).max(50).default(20), + category: z.string().optional(), + }, + }, + async ({ query, page, limit, category }) => + call(agent.api.user.guides.search.get({ query: { q: query, page, limit, category } }), { + action: 'search guides', + }), + ); + + agent.server.registerTool( + 'get_guide', + { + description: 'Get a specific guide by ID. Returns MDX/Markdown content.', + inputSchema: { guide_id: z.string() }, + }, + async ({ guide_id }) => + call(agent.api.user.guides({ id: guide_id }).get(), { + action: 'get guide', + resourceHint: `guide ${guide_id}`, + }), + ); +} diff --git a/packages/mcp/src/tools/knowledge.ts b/packages/mcp/src/tools/knowledge.ts index bdc98b9e77..261a915dbe 100644 --- a/packages/mcp/src/tools/knowledge.ts +++ b/packages/mcp/src/tools/knowledge.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { err, ok } from '../client'; +import { call } from '../client'; import type { AgentContext } from '../types'; export function registerKnowledgeTools(agent: AgentContext): void { @@ -11,106 +11,29 @@ export function registerKnowledgeTools(agent: AgentContext): void { description: 'Search the PackRat outdoor knowledge base using AI-powered retrieval. Contains expert guides on outdoor skills, safety, Leave No Trace principles, gear techniques, navigation, first aid, and outdoor activities. Use this for "how-to" questions, technique guidance, or safety information.', inputSchema: { - query: z - .string() - .min(5) - .describe( - 'Your question or search topic. Examples: "how to set up a bear hang", "layering system for cold weather camping", "water treatment methods for backcountry"', - ), - limit: z - .number() - .int() - .min(1) - .max(10) - .default(5) - .describe('Number of guide sections to return (default 5)'), + query: z.string().min(5).describe('Your question or search topic'), + limit: z.number().int().min(1).max(10).default(5), }, }, - async ({ query, limit }) => { - try { - const data = await agent.api.get('/ai/rag-search', { q: query, limit }); - return ok(data); - } catch (e) { - return err(e); - } - }, - ); - - // ── Web search ──────────────────────────────────────────────────────────── - - agent.server.registerTool( - 'web_search', - { - description: - 'Search the web for current, real-time information using Perplexity AI. Use this for: current trail conditions, recent news about parks/trails, current gear prices and deals, recent reviews, event schedules, permit availability, or anything requiring up-to-date information not in the PackRat knowledge base.', - inputSchema: { - query: z - .string() - .min(3) - .describe( - 'Search query — be specific. Examples: "John Muir Trail permit availability 2025", "best ultralight tent reviews 2025", "Yosemite Valley campground reservations"', - ), - }, - }, - async ({ query }) => { - try { - const data = await agent.api.get('/ai/web-search', { q: query }); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ query, limit }) => + call(agent.api.user.ai['rag-search'].get({ query: { q: query, limit } }), { + action: 'search outdoor guides', + }), ); - // ── Execute SQL (power user tool) ───────────────────────────────────────── + // ── Knowledge-base reader (URL extraction) ──────────────────────────────── agent.server.registerTool( - 'execute_sql_query', + 'extract_url_content', { description: - 'Execute a read-only SQL SELECT query against the PackRat database. Use this for advanced analytics, custom gear searches by specs, or exploring the data schema. Only SELECT statements are allowed — no INSERT, UPDATE, or DELETE.', - inputSchema: { - query: z - .string() - .min(10) - .describe( - 'A valid SQL SELECT statement. Example: "SELECT name, brand, weight FROM catalog_items WHERE category = \'sleeping bags\' AND weight < 500 ORDER BY weight ASC LIMIT 10"', - ), - limit: z - .number() - .int() - .min(1) - .max(500) - .default(100) - .describe('Maximum rows to return (default 100, max 500)'), - }, - }, - async ({ query, limit }) => { - try { - const data = await agent.api.post('/ai/execute-sql', { query, limit }); - return ok(data); - } catch (e) { - return err(e); - } - }, - ); - - // ── Get DB schema ───────────────────────────────────────────────────────── - - agent.server.registerTool( - 'get_database_schema', - { - description: - 'Get the PackRat database schema — table names, column names, and types. Use this before writing SQL queries to understand available data structures.', - inputSchema: {}, - }, - async () => { - try { - const data = await agent.api.get('/ai/db-schema'); - return ok(data); - } catch (e) { - return err(e); - } - }, + 'Extract the readable article content from any URL using Readability. Useful for ingesting blog posts, trip reports, or gear reviews.', + inputSchema: { url: z.string().url() }, + }, + async ({ url }) => + call(agent.api.user['knowledge-base'].reader.extract.post({ url }), { + action: 'extract URL content', + resourceHint: url, + }), ); } diff --git a/packages/mcp/src/tools/packTemplates.ts b/packages/mcp/src/tools/packTemplates.ts new file mode 100644 index 0000000000..842111e641 --- /dev/null +++ b/packages/mcp/src/tools/packTemplates.ts @@ -0,0 +1,237 @@ +import { z } from 'zod'; +import { call, nowIso, shortId } from '../client'; +import { ItemCategory, PackCategory } from '../enums'; +import type { AgentContext } from '../types'; + +export function registerPackTemplateTools(agent: AgentContext): void { + // ── Templates ───────────────────────────────────────────────────────────── + + agent.server.registerTool( + 'list_pack_templates', + { + description: 'List both user-owned and app-curated pack templates.', + inputSchema: {}, + }, + async () => + call(agent.api.user['pack-templates'].get(), { action: 'list pack templates' }), + ); + + agent.server.registerTool( + 'get_pack_template', + { + description: 'Get a pack template with its items.', + inputSchema: { template_id: z.string() }, + }, + async ({ template_id }) => + call(agent.api.user['pack-templates']({ templateId: template_id }).get(), { + action: 'get pack template', + resourceHint: `template ${template_id}`, + }), + ); + + agent.server.registerTool( + 'create_pack_template', + { + description: + 'Create a pack template. Set is_app_template=true to create a curated app template (admin only).', + inputSchema: { + name: z.string().min(1), + description: z.string().optional(), + category: z.nativeEnum(PackCategory), + image: z.string().optional(), + tags: z.array(z.string()).optional(), + is_app_template: z.boolean().default(false), + }, + }, + async ({ name, description, category, image, tags, is_app_template }) => { + const id = shortId('pt'); + const now = nowIso(); + return call( + agent.api.user['pack-templates'].post({ + id, + name, + description, + category, + image, + tags, + isAppTemplate: is_app_template, + localCreatedAt: now, + localUpdatedAt: now, + }), + { action: 'create pack template' }, + ); + }, + ); + + agent.server.registerTool( + 'update_pack_template', + { + description: 'Update a pack template.', + inputSchema: { + template_id: z.string(), + name: z.string().min(1).optional(), + description: z.string().optional(), + category: z.nativeEnum(PackCategory).optional(), + image: z.string().optional(), + tags: z.array(z.string()).optional(), + }, + }, + async ({ template_id, name, description, category, image, tags }) => { + const body: Record = { localUpdatedAt: nowIso() }; + if (name !== undefined) body.name = name; + if (description !== undefined) body.description = description; + if (category !== undefined) body.category = category; + if (image !== undefined) body.image = image; + if (tags !== undefined) body.tags = tags; + return call(agent.api.user['pack-templates']({ templateId: template_id }).put(body), { + action: 'update pack template', + resourceHint: `template ${template_id}`, + }); + }, + ); + + agent.server.registerTool( + 'delete_pack_template', + { + description: 'Delete a pack template.', + inputSchema: { template_id: z.string() }, + }, + async ({ template_id }) => + call(agent.api.user['pack-templates']({ templateId: template_id }).delete(), { + action: 'delete pack template', + resourceHint: `template ${template_id}`, + }), + ); + + // ── Template items ──────────────────────────────────────────────────────── + + agent.server.registerTool( + 'list_pack_template_items', + { + description: 'List items inside a pack template.', + inputSchema: { template_id: z.string() }, + }, + async ({ template_id }) => + call(agent.api.user['pack-templates']({ templateId: template_id }).items.get(), { + action: 'list pack template items', + resourceHint: `template ${template_id}`, + }), + ); + + agent.server.registerTool( + 'add_pack_template_item', + { + description: 'Add an item to a pack template.', + inputSchema: { + template_id: z.string(), + name: z.string().min(1), + description: z.string().optional(), + weight: z.number().min(0), + weight_unit: z.enum(['g', 'oz', 'kg', 'lb']).default('g'), + quantity: z.number().int().min(1).default(1), + category: z.nativeEnum(ItemCategory), + consumable: z.boolean().default(false), + worn: z.boolean().default(false), + image: z.string().optional(), + notes: z.string().optional(), + }, + }, + async ({ + template_id, + name, + description, + weight, + weight_unit, + quantity, + category, + consumable, + worn, + image, + notes, + }) => { + const id = shortId('pti'); + return call( + agent.api.user['pack-templates']({ templateId: template_id }).items.post({ + id, + name, + description, + weight, + weightUnit: weight_unit, + quantity, + category, + consumable, + worn, + image, + notes, + }), + { action: 'add template item', resourceHint: `template ${template_id}` }, + ); + }, + ); + + agent.server.registerTool( + 'update_pack_template_item', + { + description: 'Update a pack template item.', + inputSchema: { + item_id: z.string(), + name: z.string().min(1).optional(), + description: z.string().optional(), + weight: z.number().min(0).optional(), + weight_unit: z.enum(['g', 'oz', 'kg', 'lb']).optional(), + quantity: z.number().int().min(1).optional(), + category: z.nativeEnum(ItemCategory).optional(), + consumable: z.boolean().optional(), + worn: z.boolean().optional(), + notes: z.string().optional(), + }, + }, + async ({ item_id, ...fields }) => { + const body: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v === undefined) continue; + const camel = k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); + body[camel] = v; + } + return call(agent.api.user['pack-templates'].items({ itemId: item_id }).patch(body), { + action: 'update template item', + resourceHint: `item ${item_id}`, + }); + }, + ); + + agent.server.registerTool( + 'delete_pack_template_item', + { + description: 'Delete a pack template item.', + inputSchema: { item_id: z.string() }, + }, + async ({ item_id }) => + call(agent.api.user['pack-templates'].items({ itemId: item_id }).delete(), { + action: 'delete template item', + resourceHint: `item ${item_id}`, + }), + ); + + // ── Generate from online content (admin-only on the API side) ───────────── + + agent.server.registerTool( + 'generate_pack_template_from_url', + { + description: + 'Generate a pack template from a TikTok or YouTube link (admin-only). Use admin_login first.', + inputSchema: { + content_url: z.string().url(), + is_app_template: z.boolean().default(false), + }, + }, + async ({ content_url, is_app_template }) => + call( + agent.api.user['pack-templates']['generate-from-online-content'].post({ + contentUrl: content_url, + isAppTemplate: is_app_template, + }), + { action: 'generate pack template from URL', requiresAdmin: true }, + ), + ); +} diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index 38608be5f2..78e79751c6 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -1,11 +1,8 @@ import { z } from 'zod'; -import { err, ok } from '../client'; +import { call, nowIso, ok, shortId } from '../client'; import { ItemCategory, PackCategory } from '../enums'; import type { AgentContext } from '../types'; -// ── Tool regex constants ── -const STRIP_HYPHENS = /-/g; - interface PackDetailResponse { items?: Array<{ name: string; @@ -36,14 +33,10 @@ export function registerPackTools(agent: AgentContext): void { .describe('Include public packs from other users'), }, }, - async ({ include_public }) => { - try { - const data = await agent.api.get('/packs', { includePublic: include_public ? 1 : 0 }); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ include_public }) => + call(agent.api.user.packs.get({ query: { includePublic: include_public ? 1 : 0 } }), { + action: 'list packs', + }), ); // ── Get pack details ────────────────────────────────────────────────────── @@ -57,14 +50,11 @@ export function registerPackTools(agent: AgentContext): void { pack_id: z.string().describe('The unique pack ID (e.g. "p_abc123")'), }, }, - async ({ pack_id }) => { - try { - const data = await agent.api.get(`/packs/${pack_id}`); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ pack_id }) => + call(agent.api.user.packs({ packId: pack_id }).get(), { + action: 'get pack', + resourceHint: `pack ${pack_id}`, + }), ); // ── Create pack ─────────────────────────────────────────────────────────── @@ -86,10 +76,10 @@ export function registerPackTools(agent: AgentContext): void { }, }, async ({ name, description, category, is_public, tags }) => { - try { - const id = `p_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; - const now = new Date().toISOString(); - const data = await agent.api.post('/packs', { + const id = shortId('p'); + const now = nowIso(); + return call( + agent.api.user.packs.post({ id, name, description, @@ -98,11 +88,9 @@ export function registerPackTools(agent: AgentContext): void { tags, localCreatedAt: now, localUpdatedAt: now, - }); - return ok(data); - } catch (e) { - return err(e); - } + }), + { action: 'create pack' }, + ); }, ); @@ -122,18 +110,16 @@ export function registerPackTools(agent: AgentContext): void { }, }, async ({ pack_id, name, description, category, is_public, tags }) => { - try { - const body: Record = { localUpdatedAt: new Date().toISOString() }; - if (name !== undefined) body.name = name; - if (description !== undefined) body.description = description; - if (category !== undefined) body.category = category; - if (is_public !== undefined) body.isPublic = is_public; - if (tags !== undefined) body.tags = tags; - const data = await agent.api.put(`/packs/${pack_id}`, body); - return ok(data); - } catch (e) { - return err(e); - } + const body: Record = { localUpdatedAt: nowIso() }; + if (name !== undefined) body.name = name; + if (description !== undefined) body.description = description; + if (category !== undefined) body.category = category; + if (is_public !== undefined) body.isPublic = is_public; + if (tags !== undefined) body.tags = tags; + return call(agent.api.user.packs({ packId: pack_id }).put(body), { + action: 'update pack', + resourceHint: `pack ${pack_id}`, + }); }, ); @@ -147,14 +133,41 @@ export function registerPackTools(agent: AgentContext): void { pack_id: z.string().describe('The unique pack ID to delete'), }, }, - async ({ pack_id }) => { - try { - const data = await agent.api.delete(`/packs/${pack_id}`); - return ok(data); - } catch (e) { - return err(e); - } + async ({ pack_id }) => + call(agent.api.user.packs({ packId: pack_id }).delete(), { + action: 'delete pack', + resourceHint: `pack ${pack_id}`, + }), + ); + + // ── List pack items ─────────────────────────────────────────────────────── + + agent.server.registerTool( + 'list_pack_items', + { + description: 'List all items in a pack.', + inputSchema: { pack_id: z.string().describe('The pack ID') }, + }, + async ({ pack_id }) => + call(agent.api.user.packs({ packId: pack_id }).items.get(), { + action: 'list pack items', + resourceHint: `pack ${pack_id}`, + }), + ); + + // ── Get a single pack item ──────────────────────────────────────────────── + + agent.server.registerTool( + 'get_pack_item', + { + description: 'Get full details of a single pack item.', + inputSchema: { item_id: z.string().describe('The pack item ID') }, }, + async ({ item_id }) => + call(agent.api.user.packs.items({ itemId: item_id }).get(), { + action: 'get pack item', + resourceHint: `item ${item_id}`, + }), ); // ── Add item to pack ────────────────────────────────────────────────────── @@ -194,10 +207,10 @@ export function registerPackTools(agent: AgentContext): void { is_worn, notes, }) => { - try { - const id = `i_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; - const now = new Date().toISOString(); - const data = await agent.api.post(`/packs/${pack_id}/items`, { + const id = shortId('i'); + const now = nowIso(); + return call( + agent.api.user.packs({ packId: pack_id }).items.post({ id, name, category, @@ -209,11 +222,42 @@ export function registerPackTools(agent: AgentContext): void { notes, localCreatedAt: now, localUpdatedAt: now, - }); - return ok(data); - } catch (e) { - return err(e); - } + }), + { action: 'add pack item', resourceHint: `pack ${pack_id}` }, + ); + }, + ); + + // ── Update pack item ────────────────────────────────────────────────────── + + agent.server.registerTool( + 'update_pack_item', + { + description: 'Update fields on an existing pack item.', + inputSchema: { + item_id: z.string().describe('The pack item ID'), + name: z.string().min(1).optional(), + category: z.nativeEnum(ItemCategory).optional(), + weight_grams: z.number().min(0).optional(), + quantity: z.number().int().min(1).optional(), + is_consumable: z.boolean().optional(), + is_worn: z.boolean().optional(), + notes: z.string().nullable().optional(), + }, + }, + async ({ item_id, name, category, weight_grams, quantity, is_consumable, is_worn, notes }) => { + const body: Record = { localUpdatedAt: nowIso() }; + if (name !== undefined) body.name = name; + if (category !== undefined) body.category = category; + if (weight_grams !== undefined) body.weight = weight_grams; + if (quantity !== undefined) body.quantity = quantity; + if (is_consumable !== undefined) body.consumable = is_consumable; + if (is_worn !== undefined) body.worn = is_worn; + if (notes !== undefined) body.notes = notes; + return call(agent.api.user.packs.items({ itemId: item_id }).patch(body), { + action: 'update pack item', + resourceHint: `item ${item_id}`, + }); }, ); @@ -223,101 +267,193 @@ export function registerPackTools(agent: AgentContext): void { 'remove_pack_item', { description: 'Remove an item from a pack (soft-delete).', + inputSchema: { item_id: z.string().describe('The item ID to remove') }, + }, + async ({ item_id }) => + call(agent.api.user.packs.items({ itemId: item_id }).delete(), { + action: 'delete pack item', + resourceHint: `item ${item_id}`, + }), + ); + + // ── Similar items for an item in a pack ─────────────────────────────────── + + agent.server.registerTool( + 'similar_pack_items', + { + description: + 'Find catalog gear similar to a specific item in a pack (semantic similarity).', inputSchema: { - item_id: z.string().describe('The item ID to remove'), + pack_id: z.string(), + item_id: z.string(), + limit: z.number().int().min(1).max(50).default(10), + threshold: z.number().min(0).max(1).optional().describe('Similarity threshold (0-1)'), }, }, - async ({ item_id }) => { - try { - const data = await agent.api.delete(`/packs/items/${item_id}`); - return ok(data); - } catch (e) { - return err(e); - } + async ({ pack_id, item_id, limit, threshold }) => + call( + agent.api.user + .packs({ packId: pack_id }) + .items({ itemId: item_id }) + .similar.get({ query: { limit, ...(threshold !== undefined ? { threshold } : {}) } }), + { action: 'find similar items', resourceHint: `item ${item_id}` }, + ), + ); + + // ── Pack item suggestions ───────────────────────────────────────────────── + + agent.server.registerTool( + 'suggest_pack_items', + { + description: + 'Get AI-driven catalog item suggestions for a pack based on the items already in it.', + inputSchema: { + pack_id: z.string(), + existing_catalog_item_ids: z.array(z.number().int()).default([]), + }, + }, + async ({ pack_id, existing_catalog_item_ids }) => + call( + agent.api.user + .packs({ packId: pack_id }) + ['item-suggestions'].post({ existingCatalogItemIds: existing_catalog_item_ids }), + { action: 'suggest pack items', resourceHint: `pack ${pack_id}` }, + ), + ); + + // ── Weight history ──────────────────────────────────────────────────────── + + agent.server.registerTool( + 'get_pack_weight_history', + { + description: "Get the weight history for all of the user's packs over time.", + inputSchema: {}, + }, + async () => + call(agent.api.user.packs['weight-history'].get(), { + action: 'list pack weight history', + }), + ); + + agent.server.registerTool( + 'record_pack_weight', + { + description: 'Record a weight measurement for a pack at a specific point in time.', + inputSchema: { pack_id: z.string(), weight_grams: z.number().min(0) }, + }, + async ({ pack_id, weight_grams }) => { + const id = shortId('w'); + return call( + agent.api.user + .packs({ packId: pack_id }) + ['weight-history'].post({ id, weight: weight_grams, localCreatedAt: nowIso() }), + { action: 'record pack weight', resourceHint: `pack ${pack_id}` }, + ); }, ); // ── Pack weight analysis ────────────────────────────────────────────────── + // The API already returns totalWeight/baseWeight/wornWeight/consumableWeight + // on a pack detail but does NOT return a per-category breakdown. Candidate + // for API thickening: GET /packs/:packId/weight-breakdown. agent.server.registerTool( 'analyze_pack_weight', { description: 'Get a detailed weight breakdown for a pack by category. Returns base weight, worn weight, consumable weight, and total weight with per-category summaries. Useful for identifying the heaviest items and optimization opportunities.', - inputSchema: { - pack_id: z.string().describe('The pack ID to analyze'), - }, + inputSchema: { pack_id: z.string().describe('The pack ID to analyze') }, }, async ({ pack_id }) => { - try { - const pack = await agent.api.get(`/packs/${pack_id}`); - - const byCategory: Record = - {}; - const items = pack.items ?? []; - - for (const item of items) { - const cat = item.category || 'Uncategorized'; - const entry = byCategory[cat] ?? { items: [], totalGrams: 0, count: 0 }; - entry.items.push(`${item.name} (${item.weight}g × ${item.quantity})`); - entry.totalGrams += item.weight * item.quantity; - entry.count += item.quantity; - byCategory[cat] = entry; - } - - const analysis = { - packId: pack_id, - totalWeight: pack.totalWeight ?? 0, - baseWeight: pack.baseWeight ?? 0, - wornWeight: pack.wornWeight ?? 0, - consumableWeight: pack.consumableWeight ?? 0, - itemCount: items.length, - byCategory: Object.entries(byCategory) - .sort((a, b) => b[1].totalGrams - a[1].totalGrams) - .map(([category, stats]) => ({ - category, - totalGrams: stats.totalGrams, - totalLbs: (stats.totalGrams / 453.592).toFixed(2), - itemCount: stats.count, - items: stats.items, - })), - }; - - return ok(analysis); - } catch (e) { - return err(e); + const { data, error, status } = await agent.api.user.packs({ packId: pack_id }).get(); + if (error || !data) { + return call(Promise.resolve({ data: null, error, status }), { + action: 'analyze pack weight', + resourceHint: `pack ${pack_id}`, + }); } + const pack = data as unknown as PackDetailResponse; // safe-cast: API returns the pack detail shape used below + const byCategory: Record = {}; + const items = pack.items ?? []; + for (const item of items) { + const cat = item.category || 'Uncategorized'; + const entry = byCategory[cat] ?? { items: [], totalGrams: 0, count: 0 }; + entry.items.push(`${item.name} (${item.weight}g × ${item.quantity})`); + entry.totalGrams += item.weight * item.quantity; + entry.count += item.quantity; + byCategory[cat] = entry; + } + return ok({ + packId: pack_id, + totalWeight: pack.totalWeight ?? 0, + baseWeight: pack.baseWeight ?? 0, + wornWeight: pack.wornWeight ?? 0, + consumableWeight: pack.consumableWeight ?? 0, + itemCount: items.length, + byCategory: Object.entries(byCategory) + .sort((a, b) => b[1].totalGrams - a[1].totalGrams) + .map(([category, stats]) => ({ + category, + totalGrams: stats.totalGrams, + totalLbs: (stats.totalGrams / 453.592).toFixed(2), + itemCount: stats.count, + items: stats.items, + })), + }); }, ); - // ── Pack gap analysis ───────────────────────────────────────────────────── + // ── Gap analysis ────────────────────────────────────────────────────────── agent.server.registerTool( 'analyze_pack_gaps', { description: - "Identify missing essential gear categories for a specific activity type. Compares the pack's current categories against recommended essentials and returns what's missing.", + "Identify missing essential gear categories for a specific trip context. Compares the pack's current categories against recommended essentials and returns what's missing.", inputSchema: { pack_id: z.string().describe('The pack ID to analyze'), - activity: z.nativeEnum(PackCategory).describe('Activity type to check gear gaps for'), - duration_days: z + destination: z.string().describe('Trip destination'), + trip_type: z.nativeEnum(PackCategory).describe('Trip / activity type'), + duration_days: z.number().int().min(1).describe('Trip duration in days'), + start_date: z.string().optional().describe('ISO date for trip start'), + end_date: z.string().optional().describe('ISO date for trip end'), + }, + }, + async ({ pack_id, destination, trip_type, duration_days, start_date, end_date }) => + call( + agent.api.user.packs({ packId: pack_id })['gap-analysis'].post({ + destination, + tripType: trip_type, + duration: duration_days, + startDate: start_date, + endDate: end_date, + }), + { action: 'analyze pack gaps', resourceHint: `pack ${pack_id}` }, + ), + ); + + // ── Image-based gear detection ─────────────────────────────────────────── + + agent.server.registerTool( + 'analyze_pack_image', + { + description: + 'Submit a gear image (R2 key from upload_image_url) for AI-powered item detection. Returns detected items with catalog matches.', + inputSchema: { + image_key: z.string().describe('R2 image key from a presigned upload'), + match_limit: z .number() .int() .min(1) - .optional() - .describe('Trip duration in days (affects consumable recommendations)'), + .max(20) + .default(5) + .describe('Max catalog matches per detected item'), }, }, - async ({ pack_id, activity, duration_days }) => { - try { - const data = await agent.api.post(`/packs/${pack_id}/gap-analysis`, { - activity, - durationDays: duration_days, - }); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ image_key, match_limit }) => + call( + agent.api.user.packs['analyze-image'].post({ image: image_key, matchLimit: match_limit }), + { action: 'analyze pack image' }, + ), ); } diff --git a/packages/mcp/src/tools/seasons.ts b/packages/mcp/src/tools/seasons.ts new file mode 100644 index 0000000000..87b2a2b61e --- /dev/null +++ b/packages/mcp/src/tools/seasons.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +export function registerSeasonTools(agent: AgentContext): void { + // Note: the API requires a user with 20+ inventory items before serving + // suggestions — the call may 422 for new users. + agent.server.registerTool( + 'get_season_suggestions', + { + description: + 'Generate season-appropriate pack suggestions for a location + date. Requires at least 20 inventory items on the signed-in user.', + inputSchema: { + location: z.string().min(1).describe('Location string the API can geocode'), + date: z.string().describe('ISO 8601 date or month label'), + }, + }, + async ({ location, date }) => + call(agent.api.user['season-suggestions'].post({ location, date }), { + action: 'fetch season suggestions', + }), + ); +} diff --git a/packages/mcp/src/tools/trail-conditions.ts b/packages/mcp/src/tools/trail-conditions.ts index 111f9bd13f..4e0a819f5f 100644 --- a/packages/mcp/src/tools/trail-conditions.ts +++ b/packages/mcp/src/tools/trail-conditions.ts @@ -1,41 +1,50 @@ import { z } from 'zod'; -import { err, ok } from '../client'; +import { call, nowIso, shortId } from '../client'; import { CrossingDifficulty, TrailCondition, TrailSurface } from '../enums'; import type { AgentContext } from '../types'; -// ── Tool regex constants ── -const STRIP_HYPHENS = /-/g; - export function registerTrailConditionTools(agent: AgentContext): void { - // ── Get trail conditions ────────────────────────────────────────────────── + // ── List trail condition reports ────────────────────────────────────────── agent.server.registerTool( 'get_trail_conditions', { description: - 'Get user-submitted trail condition reports. Filter by trail name to find reports for a specific trail or area. Reports include overall condition, surface type, hazards, water crossings, and notes.', + 'Get user-submitted trail condition reports. Filter by trail name to find reports for a specific trail or area.', inputSchema: { - trail_name: z + trail_name: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), + }, + }, + async ({ trail_name, limit }) => + call( + agent.api.user['trail-conditions'].get({ + query: { trailName: trail_name, limit }, + }), + { action: 'list trail conditions' }, + ), + ); + + // ── List user's own trail reports ───────────────────────────────────────── + + agent.server.registerTool( + 'list_my_trail_reports', + { + description: 'List trail condition reports authored by the signed-in user.', + inputSchema: { + updated_since: z .string() .optional() - .describe('Trail or area name to search for (e.g. "John Muir Trail", "Half Dome")'), - limit: z - .number() - .int() - .min(1) - .max(100) - .default(20) - .describe('Maximum reports to return (default 20)'), + .describe('Only include reports updated after this ISO timestamp'), }, }, - async ({ trail_name, limit }) => { - try { - const data = await agent.api.get('/trail-conditions', { trailName: trail_name, limit }); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ updated_since }) => + call( + agent.api.user['trail-conditions'].mine.get({ + query: updated_since ? { updatedAt: updated_since } : {}, + }), + { action: 'list my trail reports' }, + ), ); // ── Submit trail condition ──────────────────────────────────────────────── @@ -44,36 +53,18 @@ export function registerTrailConditionTools(agent: AgentContext): void { 'submit_trail_condition', { description: - 'Submit a trail condition report to help the community. Provide your observations about current trail surface, overall condition, hazards, and water crossings. Requires user authentication.', + 'Submit a trail condition report to help the community. Requires user authentication.', inputSchema: { - trail_name: z.string().min(1).describe('Name of the trail or area'), - trail_region: z - .string() - .optional() - .describe('Region or state (e.g. "California", "Maine")'), - surface: z.nativeEnum(TrailSurface).describe('Current trail surface type'), - overall_condition: z.nativeEnum(TrailCondition).describe('Overall trail condition'), - hazards: z - .array(z.string()) - .optional() - .describe( - 'List of current hazards (e.g. ["loose rocks", "fallen trees", "slippery surface"])', - ), - water_crossings: z - .number() - .int() - .min(0) - .max(20) - .optional() - .describe('Number of water crossings on the trail (0–20)'), - water_crossing_difficulty: z - .nativeEnum(CrossingDifficulty) - .optional() - .describe('Difficulty of water crossings if present'), - notes: z - .string() - .optional() - .describe('Detailed observations about conditions, hazards, or recommendations'), + trail_name: z.string().min(1), + trail_region: z.string().optional(), + surface: z.nativeEnum(TrailSurface), + overall_condition: z.nativeEnum(TrailCondition), + hazards: z.array(z.string()).optional(), + water_crossings: z.number().int().min(0).max(20).optional(), + water_crossing_difficulty: z.nativeEnum(CrossingDifficulty).optional(), + notes: z.string().optional(), + photos: z.array(z.string()).optional(), + trip_id: z.string().optional(), }, }, async ({ @@ -85,11 +76,13 @@ export function registerTrailConditionTools(agent: AgentContext): void { water_crossings, water_crossing_difficulty, notes, + photos, + trip_id, }) => { - try { - const id = `tcr_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; - const now = new Date().toISOString(); - const data = await agent.api.post('/trail-conditions', { + const id = shortId('tcr'); + const now = nowIso(); + return call( + agent.api.user['trail-conditions'].post({ id, trailName: trail_name, trailRegion: trail_region ?? null, @@ -99,14 +92,78 @@ export function registerTrailConditionTools(agent: AgentContext): void { waterCrossings: water_crossings ?? 0, waterCrossingDifficulty: water_crossing_difficulty ?? null, notes: notes ?? null, - photos: [], + photos: photos ?? [], + tripId: trip_id, localCreatedAt: now, localUpdatedAt: now, - }); - return ok(data); - } catch (e) { - return err(e); + }), + { action: 'submit trail condition report' }, + ); + }, + ); + + // ── Update trail report ─────────────────────────────────────────────────── + + agent.server.registerTool( + 'update_trail_condition', + { + description: 'Update one of your own trail condition reports.', + inputSchema: { + report_id: z.string(), + trail_name: z.string().optional(), + trail_region: z.string().nullable().optional(), + surface: z.nativeEnum(TrailSurface).optional(), + overall_condition: z.nativeEnum(TrailCondition).optional(), + hazards: z.array(z.string()).optional(), + water_crossings: z.number().int().min(0).max(20).optional(), + water_crossing_difficulty: z.nativeEnum(CrossingDifficulty).nullable().optional(), + notes: z.string().nullable().optional(), + photos: z.array(z.string()).optional(), + }, + }, + async ({ + report_id, + trail_name, + trail_region, + surface, + overall_condition, + hazards, + water_crossings, + water_crossing_difficulty, + notes, + photos, + }) => { + const body: Record = { localUpdatedAt: nowIso() }; + if (trail_name !== undefined) body.trailName = trail_name; + if (trail_region !== undefined) body.trailRegion = trail_region; + if (surface !== undefined) body.surface = surface; + if (overall_condition !== undefined) body.overallCondition = overall_condition; + if (hazards !== undefined) body.hazards = hazards; + if (water_crossings !== undefined) body.waterCrossings = water_crossings; + if (water_crossing_difficulty !== undefined) { + body.waterCrossingDifficulty = water_crossing_difficulty; } + if (notes !== undefined) body.notes = notes; + if (photos !== undefined) body.photos = photos; + return call( + agent.api.user['trail-conditions']({ reportId: report_id }).put(body), + { action: 'update trail report', resourceHint: `report ${report_id}` }, + ); + }, + ); + + // ── Delete trail report ─────────────────────────────────────────────────── + + agent.server.registerTool( + 'delete_trail_condition', + { + description: 'Soft-delete one of your trail condition reports.', + inputSchema: { report_id: z.string() }, }, + async ({ report_id }) => + call(agent.api.user['trail-conditions']({ reportId: report_id }).delete(), { + action: 'delete trail report', + resourceHint: `report ${report_id}`, + }), ); } diff --git a/packages/mcp/src/tools/trails.ts b/packages/mcp/src/tools/trails.ts index 0720f9158d..34848ca71e 100644 --- a/packages/mcp/src/tools/trails.ts +++ b/packages/mcp/src/tools/trails.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { err, ok } from '../client'; +import { call } from '../client'; import type { AgentContext } from '../types'; export function registerTrailTools(agent: AgentContext): void { @@ -9,60 +9,21 @@ export function registerTrailTools(agent: AgentContext): void { 'search_trails', { description: - 'Search outdoor trails and routes from OpenStreetMap. Filter by name, sport type, and/or proximity to a location. Returns { trails: [...], hasMore: boolean } — use hasMore with offset to paginate.', + 'Search outdoor trails and routes from OpenStreetMap. Filter by name, sport type, and/or proximity to a location. Returns { trails, hasMore } — paginate via offset.', inputSchema: { - q: z - .string() - .optional() - .describe('Text to search in route names (e.g. "John Muir Trail", "Pacific Crest")'), - lat: z - .number() - .min(-90) - .max(90) - .optional() - .describe('Latitude for spatial search (requires lon)'), - lon: z - .number() - .min(-180) - .max(180) - .optional() - .describe('Longitude for spatial search (requires lat)'), - radius: z - .number() - .positive() - .max(500) - .optional() - .describe('Search radius in kilometres (default 50, max 500)'), - sport: z - .string() - .optional() - .describe('Filter by sport type: hiking, cycling, skiing, or other OSM sport values'), - limit: z - .number() - .int() - .min(1) - .max(200) - .optional() - .describe('Maximum results to return (default 50)'), - offset: z.number().int().min(0).optional().describe('Pagination offset (default 0)'), + q: z.string().optional(), + lat: z.number().min(-90).max(90).optional(), + lon: z.number().min(-180).max(180).optional(), + radius: z.number().positive().max(500).optional().describe('Radius in km (default 50)'), + sport: z.string().optional(), + limit: z.number().int().min(1).max(200).optional(), + offset: z.number().int().min(0).optional(), }, }, - async ({ q, lat, lon, radius, sport, limit, offset }) => { - try { - const data = await agent.api.get('/trails/search', { - q, - lat, - lon, - radius, - sport, - limit, - offset, - }); - return ok(data); - } catch (e) { - return err(e); - } - }, + async ({ q, lat, lon, radius, sport, limit, offset }) => + call(agent.api.user.trails.search.get({ query: { q, lat, lon, radius, sport, limit, offset } }), { + action: 'search trails', + }), ); // ── Get trail metadata ──────────────────────────────────────────────────── @@ -71,21 +32,14 @@ export function registerTrailTools(agent: AgentContext): void { 'get_trail', { description: - 'Get metadata for a specific trail by its OSM relation ID. Returns name, sport, difficulty, distance, and bounding box. Does not include full geometry — use get_trail_geometry for that.', - inputSchema: { - osm_id: z - .string() - .describe('OSM relation ID of the route (e.g. "12345678"). Get from search_trails.'), - }, - }, - async ({ osm_id }) => { - try { - const data = await agent.api.get(`/trails/${osm_id}`); - return ok(data); - } catch (e) { - return err(e); - } - }, + 'Get metadata for a specific trail by its OSM relation ID. Returns name, sport, difficulty, distance, and bounding box.', + inputSchema: { osm_id: z.string() }, + }, + async ({ osm_id }) => + call(agent.api.user.trails({ osmId: osm_id }).get(), { + action: 'get trail', + resourceHint: `trail ${osm_id}`, + }), ); // ── Get trail geometry ──────────────────────────────────────────────────── @@ -94,44 +48,13 @@ export function registerTrailTools(agent: AgentContext): void { 'get_trail_geometry', { description: - 'Get the full GeoJSON geometry for a trail. Uses pre-built geometry when available; otherwise stitches it from member OSM ways. May be slow for large routes with many segments.', - inputSchema: { - osm_id: z - .string() - .describe('OSM relation ID of the route (e.g. "12345678"). Get from search_trails.'), - }, - }, - async ({ osm_id }) => { - try { - const data = await agent.api.get(`/trails/${osm_id}/geometry`); - return ok(data); - } catch (e) { - return err(e); - } - }, - ); - - // ── AllTrails preview ───────────────────────────────────────────────────── - - agent.server.registerTool( - 'preview_alltrails_url', - { - description: - 'Fetch trail metadata (title, description, image) from an AllTrails URL using OpenGraph tags. Use this to enrich a trip or pack with information from an AllTrails link a user shares.', - inputSchema: { - url: z - .string() - .url() - .describe('Full AllTrails URL (must be https://alltrails.com/... or a subdomain)'), - }, - }, - async ({ url }) => { - try { - const data = await agent.api.post('/alltrails/preview', { url }); - return ok(data); - } catch (e) { - return err(e); - } - }, + 'Get full GeoJSON geometry for a trail. May be slow for large routes with many segments.', + inputSchema: { osm_id: z.string() }, + }, + async ({ osm_id }) => + call(agent.api.user.trails({ osmId: osm_id }).geometry.get(), { + action: 'get trail geometry', + resourceHint: `trail ${osm_id}`, + }), ); } diff --git a/packages/mcp/src/tools/trips.ts b/packages/mcp/src/tools/trips.ts index adbaa46483..561f549cfb 100644 --- a/packages/mcp/src/tools/trips.ts +++ b/packages/mcp/src/tools/trips.ts @@ -1,9 +1,12 @@ import { z } from 'zod'; -import { err, ok } from '../client'; +import { call, nowIso, shortId } from '../client'; import type { AgentContext } from '../types'; -// ── Tool regex constants ── -const STRIP_HYPHENS = /-/g; +const LocationInput = z.object({ + latitude: z.number().min(-90).max(90), + longitude: z.number().min(-180).max(180), + name: z.string().optional(), +}); export function registerTripTools(agent: AgentContext): void { // ── List trips ──────────────────────────────────────────────────────────── @@ -13,21 +16,9 @@ export function registerTripTools(agent: AgentContext): void { { description: "List all of the user's planned trips. Returns trip summaries including name, destination, dates, and linked pack.", - inputSchema: { - include_public: z - .boolean() - .default(false) - .describe('Include public trips from other users'), - }, - }, - async ({ include_public }) => { - try { - const data = await agent.api.get('/trips', { includePublic: include_public ? 1 : 0 }); - return ok(data); - } catch (e) { - return err(e); - } + inputSchema: {}, }, + async () => call(agent.api.user.trips.get(), { action: 'list trips' }), ); // ── Get trip ────────────────────────────────────────────────────────────── @@ -37,18 +28,13 @@ export function registerTripTools(agent: AgentContext): void { { description: 'Get full details for a single trip including location coordinates, dates, notes, and linked pack information.', - inputSchema: { - trip_id: z.string().describe('The unique trip ID (e.g. "t_abc123")'), - }, - }, - async ({ trip_id }) => { - try { - const data = await agent.api.get(`/trips/${trip_id}`); - return ok(data); - } catch (e) { - return err(e); - } + inputSchema: { trip_id: z.string().describe('The unique trip ID (e.g. "t_abc123")') }, }, + async ({ trip_id }) => + call(agent.api.user.trips({ tripId: trip_id }).get(), { + action: 'get trip', + resourceHint: `trip ${trip_id}`, + }), ); // ── Create trip ─────────────────────────────────────────────────────────── @@ -61,57 +47,31 @@ export function registerTripTools(agent: AgentContext): void { inputSchema: { name: z.string().min(1).describe('Trip name (e.g. "PCT Section J — Fall 2025")'), description: z.string().optional().describe('Trip description or notes'), - location_name: z - .string() - .optional() - .describe('Human-readable location name (e.g. "John Muir Trail, CA")'), - latitude: z.number().min(-90).max(90).optional().describe('Location latitude'), - longitude: z.number().min(-180).max(180).optional().describe('Location longitude'), - start_date: z - .string() - .optional() - .describe('Trip start date in ISO 8601 format (e.g. "2025-07-15T00:00:00Z")'), - end_date: z - .string() - .optional() - .describe('Trip end date in ISO 8601 format (e.g. "2025-07-22T00:00:00Z")'), + location: LocationInput.optional().describe('Optional structured location'), + start_date: z.string().optional().describe('Trip start date in ISO 8601 format'), + end_date: z.string().optional().describe('Trip end date in ISO 8601 format'), notes: z.string().optional().describe('Planning notes, permits needed, logistics'), pack_id: z.string().optional().describe('Optionally link an existing pack to this trip'), }, }, - async ({ - name, - description, - location_name, - latitude, - longitude, - start_date, - end_date, - notes, - pack_id, - }) => { - try { - const id = `t_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; - const now = new Date().toISOString(); - const data = await agent.api.post('/trips', { + async ({ name, description, location, start_date, end_date, notes, pack_id }) => { + const id = shortId('t'); + const now = nowIso(); + return call( + agent.api.user.trips.post({ id, name, description, - location: - latitude !== undefined && longitude !== undefined - ? { latitude, longitude, name: location_name } - : null, + location: location ?? null, startDate: start_date, endDate: end_date, notes, packId: pack_id, localCreatedAt: now, localUpdatedAt: now, - }); - return ok(data); - } catch (e) { - return err(e); - } + }), + { action: 'create trip' }, + ); }, ); @@ -122,52 +82,29 @@ export function registerTripTools(agent: AgentContext): void { { description: "Update an existing trip's details, dates, location, or linked pack.", inputSchema: { - trip_id: z.string().describe('The trip ID to update'), - name: z.string().min(1).optional().describe('New trip name'), - description: z.string().optional().nullable().describe('New description'), - location_name: z.string().optional().describe('New location name'), - latitude: z.number().min(-90).max(90).optional().describe('New latitude'), - longitude: z.number().min(-180).max(180).optional().describe('New longitude'), - start_date: z.string().optional().nullable().describe('New start date (ISO 8601)'), - end_date: z.string().optional().nullable().describe('New end date (ISO 8601)'), - notes: z.string().optional().nullable().describe('Updated notes'), - pack_id: z - .string() - .optional() - .nullable() - .describe('New linked pack ID (or null to unlink)'), + trip_id: z.string(), + name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + location: LocationInput.nullable().optional(), + start_date: z.string().nullable().optional(), + end_date: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + pack_id: z.string().nullable().optional(), }, }, - async ({ - trip_id, - name, - description, - location_name, - latitude, - longitude, - start_date, - end_date, - notes, - pack_id, - }) => { - try { - const body: Record = { localUpdatedAt: new Date().toISOString() }; - if (name !== undefined) body.name = name; - if (description !== undefined) body.description = description; - if (start_date !== undefined) body.startDate = start_date; - if (end_date !== undefined) body.endDate = end_date; - if (notes !== undefined) body.notes = notes; - if (pack_id !== undefined) body.packId = pack_id; - if (latitude !== undefined && longitude !== undefined) { - body.location = { latitude, longitude, name: location_name }; - } else if (location_name !== undefined) { - body.location = { name: location_name }; - } - const data = await agent.api.patch(`/trips/${trip_id}`, body); - return ok(data); - } catch (e) { - return err(e); - } + async ({ trip_id, name, description, location, start_date, end_date, notes, pack_id }) => { + const body: Record = { localUpdatedAt: nowIso() }; + if (name !== undefined) body.name = name; + if (description !== undefined) body.description = description; + if (location !== undefined) body.location = location; + if (start_date !== undefined) body.startDate = start_date; + if (end_date !== undefined) body.endDate = end_date; + if (notes !== undefined) body.notes = notes; + if (pack_id !== undefined) body.packId = pack_id; + return call(agent.api.user.trips({ tripId: trip_id }).put(body), { + action: 'update trip', + resourceHint: `trip ${trip_id}`, + }); }, ); @@ -176,18 +113,13 @@ export function registerTripTools(agent: AgentContext): void { agent.server.registerTool( 'delete_trip', { - description: 'Delete a trip (soft-delete). The trip will no longer appear in listings.', - inputSchema: { - trip_id: z.string().describe('The trip ID to delete'), - }, - }, - async ({ trip_id }) => { - try { - const data = await agent.api.delete(`/trips/${trip_id}`); - return ok(data); - } catch (e) { - return err(e); - } + description: 'Delete a trip. The trip will no longer appear in listings.', + inputSchema: { trip_id: z.string() }, }, + async ({ trip_id }) => + call(agent.api.user.trips({ tripId: trip_id }).delete(), { + action: 'delete trip', + resourceHint: `trip ${trip_id}`, + }), ); } diff --git a/packages/mcp/src/tools/upload.ts b/packages/mcp/src/tools/upload.ts new file mode 100644 index 0000000000..2331030db5 --- /dev/null +++ b/packages/mcp/src/tools/upload.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +export function registerUploadTools(agent: AgentContext): void { + agent.server.registerTool( + 'upload_image_url', + { + description: + 'Generate a presigned R2 URL the caller can PUT an image to (jpeg/png/webp, ≤10MB). Returns { uploadUrl, key } — use `key` in downstream tools (analyze_pack_image, identify_wildlife, etc.).', + inputSchema: { + file_name: z.string().min(1), + content_type: z.string().min(1), + size: z.number().int().min(1).max(10 * 1024 * 1024), + }, + }, + async ({ file_name, content_type, size }) => + call( + agent.api.user.upload.presigned.get({ + query: { fileName: file_name, contentType: content_type, size }, + }), + { action: 'create presigned upload URL' }, + ), + ); +} diff --git a/packages/mcp/src/tools/user.ts b/packages/mcp/src/tools/user.ts new file mode 100644 index 0000000000..046e6d3133 --- /dev/null +++ b/packages/mcp/src/tools/user.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +export function registerUserTools(agent: AgentContext): void { + // ── Profile ─────────────────────────────────────────────────────────────── + + agent.server.registerTool( + 'get_profile', + { + description: 'Get the authenticated user\'s profile (firstName, lastName, email, avatar).', + inputSchema: {}, + }, + async () => call(agent.api.user.user.profile.get(), { action: 'get profile' }), + ); + + agent.server.registerTool( + 'update_profile', + { + description: 'Update the authenticated user\'s profile fields.', + inputSchema: { + first_name: z.string().min(1).optional(), + last_name: z.string().min(1).optional(), + email: z.string().email().optional(), + avatar_url: z.string().url().optional(), + }, + }, + async ({ first_name, last_name, email, avatar_url }) => { + const body: Record = {}; + if (first_name !== undefined) body.firstName = first_name; + if (last_name !== undefined) body.lastName = last_name; + if (email !== undefined) body.email = email; + if (avatar_url !== undefined) body.avatarUrl = avatar_url; + return call(agent.api.user.user.profile.put(body), { action: 'update profile' }); + }, + ); +} diff --git a/packages/mcp/src/tools/weather.ts b/packages/mcp/src/tools/weather.ts index e77c5f1737..fe78d29773 100644 --- a/packages/mcp/src/tools/weather.ts +++ b/packages/mcp/src/tools/weather.ts @@ -1,50 +1,39 @@ import { z } from 'zod'; -import { err, ok } from '../client'; +import { call, errMessage } from '../client'; import type { AgentContext } from '../types'; export function registerWeatherTools(agent: AgentContext): void { - // ── Get weather ─────────────────────────────────────────────────────────── - // The PackRat weather API is a two-step flow: - // 1. GET /weather/search?q= → returns location matches with IDs - // 2. GET /weather/forecast?id= → returns the actual forecast - // This tool combines both steps for a seamless experience. + // ── Get weather (search + forecast combined) ────────────────────────────── + // Candidate for API thickening: a single GET /weather/by-name?q=... would + // collapse this two-step into one server call. agent.server.registerTool( 'get_weather', { description: - 'Get current weather conditions and multi-day forecast for any location. Returns temperature, precipitation, wind, humidity, and outdoor conditions relevant to trip planning. Works with city names, trail names, park names, or coordinates.', + 'Get current weather conditions and multi-day forecast for any location. Returns temperature, precipitation, wind, humidity, and outdoor conditions relevant to trip planning.', inputSchema: { location: z .string() .min(2) - .describe( - 'Location to get weather for. Examples: "Yosemite Valley, CA", "Mt. Whitney Summit", "Seattle, WA", "37.8651,-119.5383"', - ), + .describe('Location to get weather for (city, trail, park, etc.)'), }, }, async ({ location }) => { - try { - // Step 1: search for the location to get its ID - const searchResults = await agent.api.get>('/weather/search', { - q: location, + const search = await agent.api.user.weather.search.get({ query: { q: location } }); + if (search.error || !search.data) { + return call(Promise.resolve(search), { + action: 'search weather location', + resourceHint: location, }); - - const locationId = - searchResults.id ?? (searchResults.results as Array<{ id: string }>)?.[0]?.id; - - if (!locationId) { - return err(new Error(`No weather location found for: ${location}`)); - } - - // Step 2: fetch the forecast for that location - const forecast = await agent.api.get('/weather/forecast', { - id: String(locationId), - }); - return ok(forecast); - } catch (e) { - return err(e); } + const first = Array.isArray(search.data) ? search.data[0] : null; + const id = first && typeof first === 'object' && 'id' in first ? first.id : null; + if (id == null) return errMessage(`No weather location found for: ${location}`); + return call(agent.api.user.weather.forecast.get({ query: { id: String(id) } }), { + action: 'fetch weather forecast', + resourceHint: location, + }); }, ); @@ -53,45 +42,49 @@ export function registerWeatherTools(agent: AgentContext): void { agent.server.registerTool( 'search_weather_location', { - description: - 'Search for weather locations by name. Returns matching locations with their IDs. Use get_weather instead for a combined search+forecast in one call — use this only if you need to pick from multiple location matches.', - inputSchema: { - query: z.string().min(2).describe('Location search query (e.g. "Yosemite", "Seattle, WA")'), - }, - }, - async ({ query }) => { - try { - const data = await agent.api.get('/weather/search', { q: query }); - return ok(data); - } catch (e) { - return err(e); - } + description: 'Search for weather locations by name. Returns matching locations with IDs.', + inputSchema: { query: z.string().min(2) }, }, + async ({ query }) => + call(agent.api.user.weather.search.get({ query: { q: query } }), { + action: 'search weather location', + resourceHint: query, + }), ); - // ── Season suggestion ───────────────────────────────────────────────────── + // ── Search weather location by coordinates ──────────────────────────────── agent.server.registerTool( - 'get_season_suggestions', + 'search_weather_by_coordinates', { - description: - 'Get AI-powered suggestions for the best seasons to visit a destination and recommended activities per season. Useful for trip planning.', + description: 'Find weather locations near a latitude/longitude pair.', inputSchema: { - destination: z - .string() - .min(2) - .describe( - 'Destination to get season suggestions for (e.g. "Patagonia", "Zion National Park")', - ), + latitude: z.number().min(-90).max(90), + longitude: z.number().min(-180).max(180), }, }, - async ({ destination }) => { - try { - const data = await agent.api.post('/season-suggestions', { destination }); - return ok(data); - } catch (e) { - return err(e); - } + async ({ latitude, longitude }) => + call( + agent.api.user.weather['search-by-coordinates'].get({ + query: { lat: latitude, lon: longitude }, + }), + { action: 'search weather by coordinates' }, + ), + ); + + // ── Forecast by location id ─────────────────────────────────────────────── + + agent.server.registerTool( + 'get_weather_forecast', + { + description: + 'Fetch a 10-day forecast given a WeatherAPI location ID (returned by search_weather_location).', + inputSchema: { location_id: z.union([z.string(), z.number()]) }, }, + async ({ location_id }) => + call(agent.api.user.weather.forecast.get({ query: { id: String(location_id) } }), { + action: 'get weather forecast', + resourceHint: `location ${location_id}`, + }), ); } diff --git a/packages/mcp/src/tools/wildlife.ts b/packages/mcp/src/tools/wildlife.ts new file mode 100644 index 0000000000..470f8d40f9 --- /dev/null +++ b/packages/mcp/src/tools/wildlife.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { call } from '../client'; +import type { AgentContext } from '../types'; + +export function registerWildlifeTools(agent: AgentContext): void { + agent.server.registerTool( + 'identify_wildlife', + { + description: + 'Identify the plant or animal species in an uploaded image (provide the R2 image key from upload_image_url).', + inputSchema: { image_key: z.string() }, + }, + async ({ image_key }) => + call(agent.api.user.wildlife.identify.post({ image: image_key }), { + action: 'identify wildlife', + }), + ); +} diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index 327a608d8e..efb91bb0ed 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -3,16 +3,57 @@ * * Using a structural interface rather than the concrete PackRatMCP class avoids * the circular dependency: index.ts → tools/* → index.ts. - * PackRatMCP satisfies this interface structurally via its `server` and `api` fields. + * PackRatMCP satisfies this interface structurally via its `server`, `api`, + * `apiBaseUrl`, and `setAdminToken` fields. */ import type { OAuthHelpers } from '@cloudflare/workers-oauth-provider'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { PackRatApiClient } from './client'; +import type { McpClients } from './client'; + +/** Subset of McpServer.registerTool we use — same signature, no narrower types needed downstream. */ +export type RegisterToolFn = McpServer['registerTool']; + +/** + * Wrap `server.registerTool` with a feature-flag gate. The first argument is + * the flag name; the tool is only visible when that flag is enabled. Flag + * names match `MCP_FEATURE_FLAGS` (comma-separated env binding) or the + * runtime-toggled set passed to `setFeatureFlag`. + */ +export type RegisterFlaggedToolFn = < + // The TS types here mirror McpServer['registerTool']; we accept any args after + // the flag name and rely on the SDK to validate downstream. + TArgs extends Parameters, +>( + flag: string, + ...args: TArgs +) => ReturnType; export interface AgentContext { server: McpServer; - api: PackRatApiClient; + /** Eden Treaty clients — `api.user` for the signed-in user, `api.admin` for admin ops. */ + api: McpClients; + /** Base URL of the PackRat API (e.g. "https://packrat.world"). */ + apiBaseUrl: string; + /** Replace the per-session admin token (set by `admin_login`). */ + setAdminToken: (token: string) => void; + /** Toggle a feature flag at runtime (debug / admin-set). */ + setFeatureFlag: (flag: string, enabled: boolean) => void; + /** + * Register a tool that's only visible when the session holds an admin JWT. + * Has the same signature as `server.registerTool`. The MCP SDK's + * `enable()/disable()` toggles `tools/list_changed` notifications so the + * client's tool list stays in sync. + */ + registerAdminTool: RegisterToolFn; + /** + * Register a tool gated on a named feature flag. The tool is hidden unless + * the flag is present in `MCP_FEATURE_FLAGS` or has been toggled on at + * runtime via `setFeatureFlag`. + */ + registerFlaggedTool: RegisterFlaggedToolFn; + /** Best-effort PackRat user ID (from OAuth props). May be empty for legacy bearer flows. */ + userId?: string; } /** Cloudflare Worker environment bindings */ @@ -27,6 +68,8 @@ export interface Env { OAUTH_PROVIDER: OAuthHelpers; /** Optional pre-shared secret for dynamic client registration */ MCP_INITIAL_ACCESS_TOKEN?: string; + /** Comma-separated feature flags enabled at boot (e.g. "wildlife_id,season_suggestions"). */ + MCP_FEATURE_FLAGS?: string; } /** Properties embedded in OAuth access tokens and passed to API handlers */ @@ -35,4 +78,6 @@ export interface Props { betterAuthToken: string; /** PackRat user ID */ userId: string; + /** Optional admin JWT carried over from a successful admin login. */ + adminToken?: string; } From a9713acddef320692546a0ba458de5dadefb15c0 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 13:01:49 -0600 Subject: [PATCH 02/30] =?UTF-8?q?=E2=9C=A8=20cli:=20thin=20Eden=20Treaty?= =?UTF-8?q?=20wrapper=20for=20user=20+=20admin=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an API-talking surface to the CLI alongside the existing DuckDB analytics commands. Two cached Treaty clients (user + admin) share a base URL from ~/.packrat/config.json (env override: PACKRAT_API_URL). Token refresh on 401 is handled by createApiClient; the AuthHooks persist new tokens back to config atomically (tmp + rename, 0600 perms). User commands: auth (login/logout/register/refresh/whoami), packs (list/get/create/delete/items/gap-analysis), trips (list/get/create/delete), catalog (search/semantic/get/categories), trails (search/get/geometry), weather (search/forecast — two-step collapsed), feed (list/post/like/comment), templates (list/get/create/delete), seasons, user (profile/update), ai (rag/web/sql/schema). Admin command stub: admin/login mints the short-lived JWT via Basic auth, admin/logout clears it. Full admin command tree lands in the next commit. runApi() converts Treaty's { data, error, status } into clean stdout + a non-zero exit, with ACL-aware messages for 401/403 (admin-vs-user) and hints to run the right login command. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/package.json | 1 + packages/cli/src/api/client.ts | 78 ++++++++++ packages/cli/src/api/config.ts | 101 +++++++++++++ packages/cli/src/api/ids.ts | 15 ++ packages/cli/src/api/run.ts | 142 ++++++++++++++++++ packages/cli/src/commands/admin/login.ts | 53 +++++++ packages/cli/src/commands/admin/logout.ts | 11 ++ packages/cli/src/commands/ai/index.ts | 75 +++++++++ packages/cli/src/commands/auth/index.ts | 12 ++ packages/cli/src/commands/auth/login.ts | 67 +++++++++ packages/cli/src/commands/auth/logout.ts | 34 +++++ packages/cli/src/commands/auth/refresh.ts | 42 ++++++ packages/cli/src/commands/auth/register.ts | 62 ++++++++ packages/cli/src/commands/auth/whoami.ts | 29 ++++ packages/cli/src/commands/catalog/index.ts | 135 +++++++++++++++++ packages/cli/src/commands/feed/index.ts | 89 +++++++++++ packages/cli/src/commands/packs/create.ts | 47 ++++++ packages/cli/src/commands/packs/delete.ts | 28 ++++ .../cli/src/commands/packs/gap-analysis.ts | 41 +++++ packages/cli/src/commands/packs/get.ts | 40 +++++ packages/cli/src/commands/packs/index.ts | 13 ++ packages/cli/src/commands/packs/items.ts | 40 +++++ packages/cli/src/commands/packs/list.ts | 43 ++++++ packages/cli/src/commands/seasons/index.ts | 23 +++ packages/cli/src/commands/templates/index.ts | 99 ++++++++++++ packages/cli/src/commands/trails/index.ts | 89 +++++++++++ packages/cli/src/commands/trips/index.ts | 133 ++++++++++++++++ packages/cli/src/commands/user/index.ts | 52 +++++++ packages/cli/src/commands/weather/index.ts | 53 +++++++ packages/cli/src/index.ts | 39 +++-- 30 files changed, 1665 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/api/client.ts create mode 100644 packages/cli/src/api/config.ts create mode 100644 packages/cli/src/api/ids.ts create mode 100644 packages/cli/src/api/run.ts create mode 100644 packages/cli/src/commands/admin/login.ts create mode 100644 packages/cli/src/commands/admin/logout.ts create mode 100644 packages/cli/src/commands/ai/index.ts create mode 100644 packages/cli/src/commands/auth/index.ts create mode 100644 packages/cli/src/commands/auth/login.ts create mode 100644 packages/cli/src/commands/auth/logout.ts create mode 100644 packages/cli/src/commands/auth/refresh.ts create mode 100644 packages/cli/src/commands/auth/register.ts create mode 100644 packages/cli/src/commands/auth/whoami.ts create mode 100644 packages/cli/src/commands/catalog/index.ts create mode 100644 packages/cli/src/commands/feed/index.ts create mode 100644 packages/cli/src/commands/packs/create.ts create mode 100644 packages/cli/src/commands/packs/delete.ts create mode 100644 packages/cli/src/commands/packs/gap-analysis.ts create mode 100644 packages/cli/src/commands/packs/get.ts create mode 100644 packages/cli/src/commands/packs/index.ts create mode 100644 packages/cli/src/commands/packs/items.ts create mode 100644 packages/cli/src/commands/packs/list.ts create mode 100644 packages/cli/src/commands/seasons/index.ts create mode 100644 packages/cli/src/commands/templates/index.ts create mode 100644 packages/cli/src/commands/trails/index.ts create mode 100644 packages/cli/src/commands/trips/index.ts create mode 100644 packages/cli/src/commands/user/index.ts create mode 100644 packages/cli/src/commands/weather/index.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index c866c3eac6..cee17c5cfe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "dependencies": { "@duckdb/node-api": "catalog:", "@packrat/analytics": "workspace:*", + "@packrat/api-client": "workspace:*", "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", "chalk": "catalog:", diff --git a/packages/cli/src/api/client.ts b/packages/cli/src/api/client.ts new file mode 100644 index 0000000000..371fc69c43 --- /dev/null +++ b/packages/cli/src/api/client.ts @@ -0,0 +1,78 @@ +/** + * Eden Treaty client factory for the CLI. + * + * Two clients are exposed: + * + * - `user` — authenticated as the Better Auth user whose tokens are stored in + * `~/.packrat/config.json`. Uses `createApiClient` so 401s trigger a + * transparent refresh, with both new tokens persisted back to config. + * - `admin` — authenticated with the short-lived admin JWT minted by + * `POST /api/admin/token`. No refresh — the user just runs + * `packrat admin login` again when it expires. + * + * Both clients share a base URL resolved from config (or `PACKRAT_API_URL`). + */ + +import { type ApiClient, createApiClient } from '@packrat/api-client'; +import { type CliConfig, loadConfig, saveConfig } from './config'; + +let cachedUser: ApiClient | null = null; +let cachedAdmin: ApiClient | null = null; + +/** Get (and cache) the user-scope Treaty client. */ +export async function getUserClient(): Promise { + if (cachedUser) return cachedUser; + const config = await loadConfig(); + cachedUser = createApiClient({ + baseUrl: config.baseUrl, + auth: { + getAccessToken: () => loadConfig().then((c) => c.accessToken), + getRefreshToken: () => loadConfig().then((c) => c.refreshToken), + onAccessTokenRefreshed: async (token) => { + await saveConfig({ accessToken: token }); + }, + onRefreshTokenRefreshed: async (token) => { + await saveConfig({ refreshToken: token }); + }, + onNeedsReauth: async () => { + await saveConfig({ + accessToken: null, + refreshToken: null, + userEmail: null, + userId: null, + }); + }, + }, + }); + return cachedUser; +} + +/** Get (and cache) the admin-scope Treaty client. */ +export async function getAdminClient(): Promise { + if (cachedAdmin) return cachedAdmin; + const config = await loadConfig(); + cachedAdmin = createApiClient({ + baseUrl: config.baseUrl, + auth: { + getAccessToken: () => loadConfig().then((c) => c.adminToken), + // Admin tokens are not refreshable; on 401 we just surface the error. + getRefreshToken: () => null, + onAccessTokenRefreshed: () => {}, + onNeedsReauth: async () => { + await saveConfig({ adminToken: null, adminTokenExpiresAt: null }); + }, + }, + }); + return cachedAdmin; +} + +/** Convenience accessor for the base URL (mostly for raw Better Auth calls). */ +export async function getBaseUrl(): Promise { + const config = await loadConfig(); + return config.baseUrl; +} + +/** Returns a snapshot of the current config — read-only. */ +export async function getConfigSnapshot(): Promise { + return loadConfig(); +} diff --git a/packages/cli/src/api/config.ts b/packages/cli/src/api/config.ts new file mode 100644 index 0000000000..141122dff6 --- /dev/null +++ b/packages/cli/src/api/config.ts @@ -0,0 +1,101 @@ +/** + * `~/.packrat/config.json` store. + * + * Persists the CLI's session-level state: API base URL, the Better Auth + * access/refresh token pair, and the admin JWT. Refresh-on-401 is handled by + * `createApiClient`, which calls back into this module via the AuthHooks in + * `./client.ts` whenever new tokens arrive. + * + * On a fresh machine the config file may not exist; we lazily create the + * `~/.packrat` directory the first time we need to write. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; + +const DEFAULT_BASE_URL = 'https://packrat.world'; + +const CONFIG_DIR = join(homedir(), '.packrat'); +const CONFIG_PATH = join(CONFIG_DIR, 'config.json'); + +export const CliConfigSchema = z.object({ + baseUrl: z.string().url().default(DEFAULT_BASE_URL), + accessToken: z.string().nullable().default(null), + refreshToken: z.string().nullable().default(null), + adminToken: z.string().nullable().default(null), + adminTokenExpiresAt: z.number().nullable().default(null), + userEmail: z.string().nullable().default(null), + userId: z.string().nullable().default(null), +}); + +export type CliConfig = z.infer; + +const emptyConfig: CliConfig = { + baseUrl: DEFAULT_BASE_URL, + accessToken: null, + refreshToken: null, + adminToken: null, + adminTokenExpiresAt: null, + userEmail: null, + userId: null, +}; + +let cached: CliConfig | null = null; + +/** Read the config from disk (cached for the lifetime of the process). */ +export async function loadConfig(): Promise { + if (cached) return cached; + try { + const raw = await readFile(CONFIG_PATH, 'utf8'); + const parsed = CliConfigSchema.safeParse(JSON.parse(raw)); + cached = parsed.success ? parsed.data : emptyConfig; + } catch (e) { + if (isNotFound(e)) cached = { ...emptyConfig }; + else throw e; + } + // PACKRAT_API_URL env override always wins. Useful for local dev (e.g. + // pointing the CLI at `http://localhost:8787`). + const envOverride = process.env.PACKRAT_API_URL?.trim(); + if (envOverride) cached.baseUrl = envOverride; + return cached; +} + +/** Merge a partial update into the config and persist atomically. */ +export async function saveConfig(patch: Partial): Promise { + const current = await loadConfig(); + const next: CliConfig = { ...current, ...patch }; + await mkdir(dirname(CONFIG_PATH), { recursive: true }); + // Write to a tmp file then rename so partial writes can't corrupt config. + const tmp = `${CONFIG_PATH}.tmp`; + await writeFile(tmp, JSON.stringify(next, null, 2), { mode: 0o600 }); + const { rename } = await import('node:fs/promises'); + await rename(tmp, CONFIG_PATH); + cached = next; + return next; +} + +/** Clear all session-level tokens but keep `baseUrl`. */ +export async function clearSession(): Promise { + await saveConfig({ + accessToken: null, + refreshToken: null, + adminToken: null, + adminTokenExpiresAt: null, + userEmail: null, + userId: null, + }); +} + +/** Path to the config file, exposed for `packrat auth status`. */ +export const CONFIG_FILE_PATH = CONFIG_PATH; + +function isNotFound(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code: string }).code === 'ENOENT', + ); +} diff --git a/packages/cli/src/api/ids.ts b/packages/cli/src/api/ids.ts new file mode 100644 index 0000000000..035f1034f2 --- /dev/null +++ b/packages/cli/src/api/ids.ts @@ -0,0 +1,15 @@ +/** + * ID helpers for client-side creation. The API mostly expects the client to + * supply IDs (so offline-first stores can write before sync). Match the format + * used by the mobile app / MCP: `_<12-hex>`. + */ + +const STRIP_HYPHENS = /-/g; + +export function shortId(prefix: string): string { + return `${prefix}_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; +} + +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/packages/cli/src/api/run.ts b/packages/cli/src/api/run.ts new file mode 100644 index 0000000000..99c499af91 --- /dev/null +++ b/packages/cli/src/api/run.ts @@ -0,0 +1,142 @@ +/** + * Helpers that translate Eden Treaty's `{ data, error, status }` responses + * into CLI behaviour: print friendly errors, return clean data, exit non-zero + * when ACL/auth fails. Used by every API-talking command. + */ + +import { isObject, isString } from '@packrat/guards'; +import chalk from 'chalk'; +import consola from 'consola'; +import { loadConfig } from './config'; + +export type TreatyResponse = { + data: T | null; + error: { status: number; value: unknown } | null; + status: number; +}; + +export type RunOptions = { + /** Verb phrase shown in error messages, e.g. "list packs". */ + action: string; + /** Resource hint shown when 403/404 fires. */ + resourceHint?: string; + /** True when the call hits an admin-only route. */ + requiresAdmin?: boolean; +}; + +/** + * Await a Treaty call, return `data` on success, or print a friendly error and + * `process.exit(1)`. Never returns null. + */ +export async function runApi( + promise: Promise>, + opts: RunOptions, +): Promise { + const result = await promise; + if (result.error || result.data == null) { + printError(result.status, result.error?.value, opts); + process.exit(1); + } + return result.data; +} + +/** + * Variant that does NOT exit on error — returns a discriminated union. Useful + * when the command wants to react to a failure (e.g. retry, fallback). + */ +export async function tryApi( + promise: Promise>, +): Promise<{ ok: true; data: T } | { ok: false; status: number; value: unknown }> { + const result = await promise; + if (result.error || result.data == null) { + return { ok: false, status: result.status, value: result.error?.value ?? null }; + } + return { ok: true, data: result.data }; +} + +/** Confirm a user is signed in; exit with a helpful hint if not. */ +export async function requireAuth(): Promise { + const config = await loadConfig(); + if (!config.accessToken) { + consola.error( + `Not signed in. Run ${chalk.cyan('packrat auth login')} to authenticate first.`, + ); + process.exit(1); + } +} + +/** Confirm an admin JWT is on disk and hasn't visibly expired. */ +export async function requireAdmin(): Promise { + const config = await loadConfig(); + if (!config.adminToken) { + consola.error( + `No admin token. Run ${chalk.cyan('packrat admin login')} to mint one (you'll need the admin Basic credentials).`, + ); + process.exit(1); + } + if (config.adminTokenExpiresAt && config.adminTokenExpiresAt < Date.now()) { + consola.error( + `Admin token expired. Run ${chalk.cyan('packrat admin login')} to re-authenticate.`, + ); + process.exit(1); + } +} + +function printError(status: number, body: unknown, opts: RunOptions): void { + const action = opts.action; + const resource = opts.resourceHint ? ` (${opts.resourceHint})` : ''; + const detail = extractMessage(body); + const suffix = detail ? `\n ${chalk.dim(detail)}` : ''; + + if (status === 401) { + if (opts.requiresAdmin) { + consola.error( + `Admin authentication required to ${action}${resource}. ` + + `Run ${chalk.cyan('packrat admin login')} first.${suffix}`, + ); + return; + } + consola.error( + `Not signed in or session expired (${action}${resource}). ` + + `Run ${chalk.cyan('packrat auth login')}.${suffix}`, + ); + return; + } + if (status === 403) { + if (opts.requiresAdmin) { + consola.error( + `Forbidden: this is an admin-only operation (${action}${resource}). ` + + `Your account lacks the admin role.${suffix}`, + ); + return; + } + consola.error( + `Forbidden: you don't own this resource (${action}${resource}), or the API rejected the call.${suffix}`, + ); + return; + } + if (status === 404) { + consola.error(`Not found: ${action}${resource} returned 404.${suffix}`); + return; + } + if (status === 409) consola.error(`Conflict on ${action}${resource}.${suffix}`); + else if (status === 422) consola.error(`Validation failed on ${action}${resource}.${suffix}`); + else if (status === 429) consola.error(`Rate limited on ${action}${resource}.${suffix}`); + else consola.error(`${action}${resource} failed (HTTP ${status})${suffix}`); +} + +function extractMessage(body: unknown): string | null { + if (body == null) return null; + if (isString(body)) return body; + if (isObject(body)) { + const obj = body as Record; + if (isString(obj.message)) return obj.message; + if (isString(obj.error)) return obj.error; + try { + return JSON.stringify(body); + } catch { + return null; + } + } + return String(body); +} diff --git a/packages/cli/src/commands/admin/login.ts b/packages/cli/src/commands/admin/login.ts new file mode 100644 index 0000000000..7f21450e3b --- /dev/null +++ b/packages/cli/src/commands/admin/login.ts @@ -0,0 +1,53 @@ +import chalk from 'chalk'; +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { z } from 'zod'; +import { getBaseUrl } from '../../api/client'; +import { saveConfig } from '../../api/config'; + +const TokenResponseSchema = z.object({ + token: z.string(), + expiresIn: z.number(), +}); + +export default defineCommand({ + meta: { + name: 'login', + description: 'Exchange admin Basic credentials for a short-lived admin JWT (60 min).', + }, + args: { + username: { type: 'string', alias: 'u', description: 'Admin username' }, + password: { type: 'string', alias: 'p', description: 'Admin password (prompted if omitted)' }, + }, + async run({ args }) { + const username = args.username ?? (await consola.prompt('Admin username', { type: 'text' })); + const password = + args.password ?? (await consola.prompt('Admin password', { type: 'text', cancel: 'reject' })); + + const baseUrl = await getBaseUrl(); + const basic = Buffer.from(`${username}:${password}`).toString('base64'); + const response = await fetch(`${baseUrl}/api/admin/token`, { + method: 'POST', + headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/json' }, + body: '{}', + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + consola.error(`Admin login failed (HTTP ${response.status}).`); + if (body) consola.error(chalk.dim(body)); + process.exit(1); + } + + const parsed = TokenResponseSchema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) { + consola.error('Token endpoint returned an unexpected payload.'); + process.exit(1); + } + const expiresAt = Date.now() + parsed.data.expiresIn * 1000; + await saveConfig({ adminToken: parsed.data.token, adminTokenExpiresAt: expiresAt }); + consola.success( + `Admin token stored (valid for ${Math.round(parsed.data.expiresIn / 60)} min).`, + ); + }, +}); diff --git a/packages/cli/src/commands/admin/logout.ts b/packages/cli/src/commands/admin/logout.ts new file mode 100644 index 0000000000..0d6d9ea8ef --- /dev/null +++ b/packages/cli/src/commands/admin/logout.ts @@ -0,0 +1,11 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { saveConfig } from '../../api/config'; + +export default defineCommand({ + meta: { name: 'logout', description: 'Forget the stored admin JWT.' }, + async run() { + await saveConfig({ adminToken: null, adminTokenExpiresAt: null }); + consola.success('Admin token cleared.'); + }, +}); diff --git a/packages/cli/src/commands/ai/index.ts b/packages/cli/src/commands/ai/index.ts new file mode 100644 index 0000000000..eddae9fb4c --- /dev/null +++ b/packages/cli/src/commands/ai/index.ts @@ -0,0 +1,75 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; + +const ragCmd = defineCommand({ + meta: { name: 'rag', description: 'Search the outdoor guides RAG corpus.' }, + args: { + q: { type: 'positional', required: true, description: 'Question or topic' }, + limit: { type: 'string', default: '5' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi( + client.ai['rag-search'].get({ + query: { q: args.q, limit: Number.parseInt(args.limit, 10) }, + }), + { action: 'rag search' }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const webCmd = defineCommand({ + meta: { name: 'web', description: 'Perplexity-powered web search.' }, + args: { q: { type: 'positional', required: true, description: 'Search query' } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client.ai['web-search'].get({ query: { q: args.q } }), { + action: 'web search', + }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const sqlCmd = defineCommand({ + meta: { name: 'sql', description: 'Execute a read-only SQL SELECT against the API DB.' }, + args: { + query: { type: 'positional', required: true, description: 'SQL statement' }, + limit: { type: 'string', default: '100' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi( + client.ai['execute-sql'].post({ + query: args.query, + limit: Number.parseInt(args.limit, 10), + }), + { action: 'execute sql' }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const schemaCmd = defineCommand({ + meta: { name: 'schema', description: 'Print the API database schema.' }, + async run() { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client.ai['db-schema'].get(), { action: 'fetch db schema' }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'ai', description: 'AI / RAG / SQL / web-search helpers (renamed from analytics SQL).' }, + subCommands: { + rag: () => Promise.resolve(ragCmd), + web: () => Promise.resolve(webCmd), + sql: () => Promise.resolve(sqlCmd), + schema: () => Promise.resolve(schemaCmd), + }, +}); diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts new file mode 100644 index 0000000000..f621272291 --- /dev/null +++ b/packages/cli/src/commands/auth/index.ts @@ -0,0 +1,12 @@ +import { defineCommand } from 'citty'; + +export default defineCommand({ + meta: { name: 'auth', description: 'Sign in, sign out, and inspect the PackRat session.' }, + subCommands: { + login: () => import('./login').then((m) => m.default), + logout: () => import('./logout').then((m) => m.default), + register: () => import('./register').then((m) => m.default), + refresh: () => import('./refresh').then((m) => m.default), + whoami: () => import('./whoami').then((m) => m.default), + }, +}); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts new file mode 100644 index 0000000000..7f9880763b --- /dev/null +++ b/packages/cli/src/commands/auth/login.ts @@ -0,0 +1,67 @@ +import chalk from 'chalk'; +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { z } from 'zod'; +import { getBaseUrl } from '../../api/client'; +import { saveConfig } from '../../api/config'; + +const SignInResponseSchema = z.object({ + session: z.object({ token: z.string() }).optional(), + user: z.object({ id: z.string(), email: z.string().email().optional() }).optional(), + // Better Auth may also return a refresh token at top level. + refreshToken: z.string().optional(), +}); + +export default defineCommand({ + meta: { + name: 'login', + description: 'Sign in to PackRat (email + password). Token stored in ~/.packrat/config.json.', + }, + args: { + email: { type: 'string', alias: 'e', description: 'Email address' }, + password: { type: 'string', alias: 'p', description: 'Password (prompted if omitted)' }, + }, + async run({ args }) { + const email = args.email ?? (await consola.prompt('Email', { type: 'text' })); + const password = + args.password ?? (await consola.prompt('Password', { type: 'text', cancel: 'reject' })); + + if (!email || !password) { + consola.error('Email and password are required.'); + process.exit(1); + } + + const baseUrl = await getBaseUrl(); + const response = await fetch(`${baseUrl}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + consola.error(`Sign-in failed (HTTP ${response.status})`); + const body = await response.text().catch(() => ''); + if (body) consola.error(chalk.dim(body)); + process.exit(1); + } + + const parsed = SignInResponseSchema.safeParse(await response.json().catch(() => null)); + const token = parsed.success ? parsed.data.session?.token : undefined; + const userId = parsed.success ? parsed.data.user?.id : undefined; + const refreshToken = parsed.success ? parsed.data.refreshToken : undefined; + + if (!token || !userId) { + consola.error('Sign-in succeeded but session payload was missing token/user.'); + process.exit(1); + } + + await saveConfig({ + accessToken: token, + refreshToken: refreshToken ?? null, + userEmail: email, + userId, + }); + + consola.success(`Signed in as ${chalk.cyan(email)}.`); + }, +}); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts new file mode 100644 index 0000000000..786fc930f1 --- /dev/null +++ b/packages/cli/src/commands/auth/logout.ts @@ -0,0 +1,34 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getBaseUrl } from '../../api/client'; +import { clearSession, loadConfig } from '../../api/config'; + +export default defineCommand({ + meta: { + name: 'logout', + description: 'Clear the local PackRat session. Optionally signs out on the server too.', + }, + args: { + 'keep-server-session': { + type: 'boolean', + description: 'Skip POST /api/auth/sign-out — only clear local tokens', + default: false, + }, + }, + async run({ args }) { + const config = await loadConfig(); + if (!args['keep-server-session'] && config.accessToken) { + try { + const baseUrl = await getBaseUrl(); + await fetch(`${baseUrl}/api/auth/sign-out`, { + method: 'POST', + headers: { Authorization: `Bearer ${config.accessToken}` }, + }); + } catch (e) { + consola.warn(`Server sign-out failed: ${e instanceof Error ? e.message : String(e)}`); + } + } + await clearSession(); + consola.success('Signed out.'); + }, +}); diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts new file mode 100644 index 0000000000..35326a1aed --- /dev/null +++ b/packages/cli/src/commands/auth/refresh.ts @@ -0,0 +1,42 @@ +import chalk from 'chalk'; +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { z } from 'zod'; +import { getBaseUrl } from '../../api/client'; +import { loadConfig, saveConfig } from '../../api/config'; + +const RefreshResponseSchema = z.object({ + success: z.boolean().optional(), + accessToken: z.string().optional(), + refreshToken: z.string().optional(), +}); + +export default defineCommand({ + meta: { + name: 'refresh', + description: 'Force a token refresh using the stored refresh token.', + }, + async run() { + const config = await loadConfig(); + if (!config.refreshToken) { + consola.error(`No refresh token. Run ${chalk.cyan('packrat auth login')} first.`); + process.exit(1); + } + const baseUrl = await getBaseUrl(); + const response = await fetch(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: config.refreshToken }), + }); + const parsed = RefreshResponseSchema.safeParse(await response.json().catch(() => null)); + if (!response.ok || !parsed.success || !parsed.data.accessToken) { + consola.error(`Refresh failed (HTTP ${response.status}). Sign in again.`); + process.exit(1); + } + await saveConfig({ + accessToken: parsed.data.accessToken, + refreshToken: parsed.data.refreshToken ?? config.refreshToken, + }); + consola.success('Refreshed access token.'); + }, +}); diff --git a/packages/cli/src/commands/auth/register.ts b/packages/cli/src/commands/auth/register.ts new file mode 100644 index 0000000000..839b2dc66b --- /dev/null +++ b/packages/cli/src/commands/auth/register.ts @@ -0,0 +1,62 @@ +import chalk from 'chalk'; +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { z } from 'zod'; +import { getBaseUrl } from '../../api/client'; +import { saveConfig } from '../../api/config'; + +const SignUpResponseSchema = z.object({ + session: z.object({ token: z.string() }).optional(), + user: z.object({ id: z.string(), email: z.string().email().optional() }).optional(), + refreshToken: z.string().optional(), +}); + +export default defineCommand({ + meta: { + name: 'register', + description: 'Create a new PackRat account (Better Auth email + password).', + }, + args: { + email: { type: 'string', alias: 'e', description: 'Email address' }, + password: { type: 'string', alias: 'p', description: 'Password (prompted if omitted)' }, + name: { type: 'string', alias: 'n', description: 'Display name' }, + }, + async run({ args }) { + const email = args.email ?? (await consola.prompt('Email', { type: 'text' })); + const name = args.name ?? (await consola.prompt('Name', { type: 'text' })); + const password = + args.password ?? (await consola.prompt('Password', { type: 'text', cancel: 'reject' })); + + const baseUrl = await getBaseUrl(); + const response = await fetch(`${baseUrl}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, name }), + }); + + if (!response.ok) { + consola.error(`Sign-up failed (HTTP ${response.status})`); + const body = await response.text().catch(() => ''); + if (body) consola.error(chalk.dim(body)); + process.exit(1); + } + + const parsed = SignUpResponseSchema.safeParse(await response.json().catch(() => null)); + const token = parsed.success ? parsed.data.session?.token : undefined; + const userId = parsed.success ? parsed.data.user?.id : undefined; + const refreshToken = parsed.success ? parsed.data.refreshToken : undefined; + + if (!token || !userId) { + consola.success('Account created. Run `packrat auth login` to sign in.'); + return; + } + + await saveConfig({ + accessToken: token, + refreshToken: refreshToken ?? null, + userEmail: email, + userId, + }); + consola.success(`Account created and signed in as ${chalk.cyan(email)}.`); + }, +}); diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts new file mode 100644 index 0000000000..2ffaed5035 --- /dev/null +++ b/packages/cli/src/commands/auth/whoami.ts @@ -0,0 +1,29 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getUserClient } from '../../api/client'; +import { CONFIG_FILE_PATH, loadConfig } from '../../api/config'; +import { requireAuth, runApi } from '../../api/run'; +import { printSummary } from '../../shared'; + +export default defineCommand({ + meta: { name: 'whoami', description: 'Show the current PackRat user and config path.' }, + async run() { + await requireAuth(); + const client = await getUserClient(); + const profile = await runApi(client.user.profile.get(), { action: 'fetch profile' }); + const config = await loadConfig(); + printSummary( + { + baseUrl: config.baseUrl, + userId: config.userId ?? '—', + email: config.userEmail ?? '—', + firstName: (profile as Record).firstName ?? '—', + lastName: (profile as Record).lastName ?? '—', + adminTokenSet: Boolean(config.adminToken), + configFile: CONFIG_FILE_PATH, + }, + 'PackRat session', + ); + consola.success('Session looks healthy.'); + }, +}); diff --git a/packages/cli/src/commands/catalog/index.ts b/packages/cli/src/commands/catalog/index.ts new file mode 100644 index 0000000000..e1e9f5f0fb --- /dev/null +++ b/packages/cli/src/commands/catalog/index.ts @@ -0,0 +1,135 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; +import { printSummary, printTable } from '../../shared'; + +const searchCmd = defineCommand({ + meta: { name: 'search', description: 'Text search the gear catalog.' }, + args: { + q: { type: 'positional', required: true, description: 'Search keyword' }, + category: { type: 'string', alias: 'c' }, + limit: { type: 'string', alias: 'l', default: '10' }, + page: { type: 'string', default: '1' }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const limit = Number.parseInt(args.limit, 10); + const page = Number.parseInt(args.page, 10); + const data = await runApi( + client.catalog.get({ + query: { q: args.q, category: args.category, limit, page }, + }), + { action: 'search catalog' }, + ); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const items = Array.isArray((data as Record).items) + ? ((data as Record).items as Record[]) + : []; + printTable( + items.map((it) => ({ + id: it.id, + name: typeof it.name === 'string' ? it.name.slice(0, 60) : it.name, + brand: it.brand, + weight: it.weight, + price: it.price, + rating: it.ratingValue, + })), + { title: `Catalog "${args.q}"` }, + ); + }, +}); + +const semanticCmd = defineCommand({ + meta: { name: 'semantic', description: 'Semantic / vector search.' }, + args: { + q: { type: 'positional', required: true, description: 'Natural-language query' }, + limit: { type: 'string', alias: 'l', default: '8' }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const limit = Number.parseInt(args.limit, 10); + const data = await runApi( + client.catalog['vector-search'].get({ query: { q: args.q, limit } }), + { action: 'semantic catalog search' }, + ); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const items = Array.isArray((data as Record).items) + ? ((data as Record).items as Record[]) + : []; + printTable( + items.map((it) => ({ + id: it.id, + name: typeof it.name === 'string' ? it.name.slice(0, 60) : it.name, + brand: it.brand, + score: it.score, + })), + { title: `Semantic: "${args.q}"` }, + ); + }, +}); + +const getCmd = defineCommand({ + meta: { name: 'get', description: 'Get a catalog item by ID.' }, + args: { + id: { type: 'positional', required: true, description: 'Catalog item ID (numeric)' }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const item = await runApi(client.catalog({ id: args.id }).get(), { + action: 'get catalog item', + resourceHint: `item ${args.id}`, + }); + if (args.json) { + process.stdout.write(`${JSON.stringify(item, null, 2)}\n`); + return; + } + const r = item as Record; + printSummary( + { + id: r.id, + name: r.name, + brand: r.brand, + weight: r.weight, + price: r.price, + rating: r.ratingValue, + reviewCount: r.ratingCount, + productUrl: r.productUrl, + }, + `Item ${r.id}`, + ); + }, +}); + +const categoriesCmd = defineCommand({ + meta: { name: 'categories', description: 'List catalog categories.' }, + async run() { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client.catalog.categories.get({ query: {} }), { + action: 'list catalog categories', + }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'catalog', description: 'Search and inspect the PackRat gear catalog.' }, + subCommands: { + search: () => Promise.resolve(searchCmd), + semantic: () => Promise.resolve(semanticCmd), + get: () => Promise.resolve(getCmd), + categories: () => Promise.resolve(categoriesCmd), + }, +}); diff --git a/packages/cli/src/commands/feed/index.ts b/packages/cli/src/commands/feed/index.ts new file mode 100644 index 0000000000..4ea5ea5e64 --- /dev/null +++ b/packages/cli/src/commands/feed/index.ts @@ -0,0 +1,89 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; + +const listCmd = defineCommand({ + meta: { name: 'list', description: 'List feed posts.' }, + args: { + page: { type: 'string', default: '1' }, + limit: { type: 'string', default: '20' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi( + client.feed.get({ + query: { page: Number.parseInt(args.page, 10), limit: Number.parseInt(args.limit, 10) }, + }), + { action: 'list feed' }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const postCmd = defineCommand({ + meta: { name: 'post', description: 'Create a feed post.' }, + args: { + caption: { type: 'positional', required: true, description: 'Caption text' }, + images: { type: 'string', description: 'Comma-separated image keys' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const images = args.images + ? args.images + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : []; + const data = await runApi(client.feed.post({ caption: args.caption, images }), { + action: 'create feed post', + }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const likeCmd = defineCommand({ + meta: { name: 'like', description: 'Toggle like on a feed post.' }, + args: { id: { type: 'positional', required: true, description: 'Post ID' } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client.feed({ postId: args.id }).like.post({}), { + action: 'toggle post like', + resourceHint: `post ${args.id}`, + }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const commentCmd = defineCommand({ + meta: { name: 'comment', description: 'Comment on a feed post.' }, + args: { + id: { type: 'positional', required: true, description: 'Post ID' }, + content: { type: 'string', required: true, description: 'Comment text' }, + parent: { type: 'string', description: 'Parent comment ID for replies' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi( + client.feed({ postId: args.id }).comments.post({ + content: args.content, + parentCommentId: args.parent, + }), + { action: 'create feed comment', resourceHint: `post ${args.id}` }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'feed', description: 'Social feed posts, likes, and comments.' }, + subCommands: { + list: () => Promise.resolve(listCmd), + post: () => Promise.resolve(postCmd), + like: () => Promise.resolve(likeCmd), + comment: () => Promise.resolve(commentCmd), + }, +}); diff --git a/packages/cli/src/commands/packs/create.ts b/packages/cli/src/commands/packs/create.ts new file mode 100644 index 0000000000..b09282fe2f --- /dev/null +++ b/packages/cli/src/commands/packs/create.ts @@ -0,0 +1,47 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getUserClient } from '../../api/client'; +import { nowIso, shortId } from '../../api/ids'; +import { requireAuth, runApi } from '../../api/run'; + +export default defineCommand({ + meta: { name: 'create', description: 'Create a new pack.' }, + args: { + name: { type: 'positional', description: 'Pack name', required: true }, + category: { + type: 'string', + alias: 'c', + description: 'Pack category (backpacking, camping, hiking, ...)', + default: 'general', + }, + description: { type: 'string', alias: 'd', description: 'Optional description' }, + public: { type: 'boolean', description: 'Make pack public', default: false }, + tags: { type: 'string', description: 'Comma-separated tags' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const id = shortId('p'); + const now = nowIso(); + const tags = args.tags + ? args.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : undefined; + const pack = await runApi( + client.packs.post({ + id, + name: args.name, + description: args.description, + category: args.category, + isPublic: args.public, + tags, + localCreatedAt: now, + localUpdatedAt: now, + }), + { action: 'create pack' }, + ); + consola.success(`Created pack ${(pack as Record).id ?? id}`); + }, +}); diff --git a/packages/cli/src/commands/packs/delete.ts b/packages/cli/src/commands/packs/delete.ts new file mode 100644 index 0000000000..f47f423e66 --- /dev/null +++ b/packages/cli/src/commands/packs/delete.ts @@ -0,0 +1,28 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; + +export default defineCommand({ + meta: { name: 'delete', description: 'Soft-delete a pack.' }, + args: { + id: { type: 'positional', description: 'Pack ID', required: true }, + yes: { type: 'boolean', alias: 'y', description: 'Skip confirmation', default: false }, + }, + async run({ args }) { + await requireAuth(); + if (!args.yes) { + const confirm = await consola.prompt(`Delete pack ${args.id}?`, { type: 'confirm' }); + if (!confirm) { + consola.info('Aborted.'); + return; + } + } + const client = await getUserClient(); + await runApi(client.packs({ packId: args.id }).delete(), { + action: 'delete pack', + resourceHint: `pack ${args.id}`, + }); + consola.success(`Deleted ${args.id}.`); + }, +}); diff --git a/packages/cli/src/commands/packs/gap-analysis.ts b/packages/cli/src/commands/packs/gap-analysis.ts new file mode 100644 index 0000000000..d4916eeada --- /dev/null +++ b/packages/cli/src/commands/packs/gap-analysis.ts @@ -0,0 +1,41 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; + +export default defineCommand({ + meta: { + name: 'gap-analysis', + description: 'Run gear gap analysis for a pack against a planned trip.', + }, + args: { + id: { type: 'positional', description: 'Pack ID', required: true }, + destination: { type: 'string', required: true, description: 'Trip destination' }, + 'trip-type': { + type: 'string', + default: 'backpacking', + description: 'Trip / activity type (backpacking, camping, hiking, ...)', + }, + duration: { + type: 'string', + default: '3', + description: 'Trip duration in days', + }, + start: { type: 'string', description: 'ISO start date' }, + end: { type: 'string', description: 'ISO end date' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const result = await runApi( + client.packs({ packId: args.id })['gap-analysis'].post({ + destination: args.destination, + tripType: args['trip-type'], + duration: Number.parseInt(args.duration, 10), + startDate: args.start, + endDate: args.end, + }), + { action: 'analyze pack gaps', resourceHint: `pack ${args.id}` }, + ); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + }, +}); diff --git a/packages/cli/src/commands/packs/get.ts b/packages/cli/src/commands/packs/get.ts new file mode 100644 index 0000000000..2c5acc4cde --- /dev/null +++ b/packages/cli/src/commands/packs/get.ts @@ -0,0 +1,40 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; +import { printSummary } from '../../shared'; + +export default defineCommand({ + meta: { name: 'get', description: 'Get a single pack with items and weight totals.' }, + args: { + id: { type: 'positional', description: 'Pack ID', required: true }, + json: { type: 'boolean', description: 'Print raw JSON', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const pack = await runApi(client.packs({ packId: args.id }).get(), { + action: 'get pack', + resourceHint: `pack ${args.id}`, + }); + if (args.json) { + process.stdout.write(`${JSON.stringify(pack, null, 2)}\n`); + return; + } + const p = pack as Record; + printSummary( + { + id: p.id, + name: p.name, + category: p.category, + description: p.description, + totalGrams: p.totalWeight, + baseGrams: p.baseWeight, + wornGrams: p.wornWeight, + consumableGrams: p.consumableWeight, + isPublic: p.isPublic, + items: Array.isArray(p.items) ? p.items.length : 0, + }, + `Pack ${p.name ?? args.id}`, + ); + }, +}); diff --git a/packages/cli/src/commands/packs/index.ts b/packages/cli/src/commands/packs/index.ts new file mode 100644 index 0000000000..3712f22e5d --- /dev/null +++ b/packages/cli/src/commands/packs/index.ts @@ -0,0 +1,13 @@ +import { defineCommand } from 'citty'; + +export default defineCommand({ + meta: { name: 'packs', description: 'List, create, and manage your PackRat packs.' }, + subCommands: { + list: () => import('./list').then((m) => m.default), + get: () => import('./get').then((m) => m.default), + create: () => import('./create').then((m) => m.default), + delete: () => import('./delete').then((m) => m.default), + items: () => import('./items').then((m) => m.default), + 'gap-analysis': () => import('./gap-analysis').then((m) => m.default), + }, +}); diff --git a/packages/cli/src/commands/packs/items.ts b/packages/cli/src/commands/packs/items.ts new file mode 100644 index 0000000000..c40e99e3c5 --- /dev/null +++ b/packages/cli/src/commands/packs/items.ts @@ -0,0 +1,40 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +export default defineCommand({ + meta: { name: 'items', description: 'List items in a pack.' }, + args: { + id: { type: 'positional', description: 'Pack ID', required: true }, + json: { type: 'boolean', description: 'Print raw JSON', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const items = await runApi(client.packs({ packId: args.id }).items.get(), { + action: 'list pack items', + resourceHint: `pack ${args.id}`, + }); + if (args.json) { + process.stdout.write(`${JSON.stringify(items, null, 2)}\n`); + return; + } + const rows = Array.isArray(items) ? items : []; + printTable( + rows.map((it) => { + const r = it as Record; + return { + id: r.id, + name: r.name, + category: r.category, + weight: r.weight, + qty: r.quantity, + worn: r.worn, + consumable: r.consumable, + }; + }), + { title: `Items in ${args.id}` }, + ); + }, +}); diff --git a/packages/cli/src/commands/packs/list.ts b/packages/cli/src/commands/packs/list.ts new file mode 100644 index 0000000000..b259e2866b --- /dev/null +++ b/packages/cli/src/commands/packs/list.ts @@ -0,0 +1,43 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +export default defineCommand({ + meta: { name: 'list', description: 'List your packs.' }, + args: { + 'include-public': { + type: 'boolean', + description: 'Include public packs from other users', + default: false, + }, + json: { type: 'boolean', description: 'Print raw JSON', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const includePublic = args['include-public'] ? 1 : 0; + const packs = await runApi(client.packs.get({ query: { includePublic } }), { + action: 'list packs', + }); + if (args.json) { + process.stdout.write(`${JSON.stringify(packs, null, 2)}\n`); + return; + } + const rows = Array.isArray(packs) ? packs : []; + printTable( + rows.map((p) => { + const r = p as Record; + return { + id: r.id, + name: r.name, + category: r.category, + items: r.itemCount, + totalGrams: r.totalWeight, + isPublic: r.isPublic, + }; + }), + { title: 'Your packs' }, + ); + }, +}); diff --git a/packages/cli/src/commands/seasons/index.ts b/packages/cli/src/commands/seasons/index.ts new file mode 100644 index 0000000000..bf0580f2bb --- /dev/null +++ b/packages/cli/src/commands/seasons/index.ts @@ -0,0 +1,23 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; + +export default defineCommand({ + meta: { + name: 'seasons', + description: 'Generate season-appropriate pack suggestions for a location + date.', + }, + args: { + location: { type: 'string', required: true, description: 'Geocodable location string' }, + date: { type: 'string', required: true, description: 'ISO date or month label' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi( + client['season-suggestions'].post({ location: args.location, date: args.date }), + { action: 'season suggestions' }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); diff --git a/packages/cli/src/commands/templates/index.ts b/packages/cli/src/commands/templates/index.ts new file mode 100644 index 0000000000..30e8df3889 --- /dev/null +++ b/packages/cli/src/commands/templates/index.ts @@ -0,0 +1,99 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { nowIso, shortId } from '../../api/ids'; +import { requireAuth, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +const listCmd = defineCommand({ + meta: { name: 'list', description: 'List pack templates (user + app curated).' }, + args: { json: { type: 'boolean', default: false } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client['pack-templates'].get(), { action: 'list pack templates' }); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const rows = Array.isArray(data) ? data : []; + printTable( + rows.map((t) => { + const r = t as Record; + return { + id: r.id, + name: r.name, + category: r.category, + isApp: r.isAppTemplate, + }; + }), + { title: 'Pack templates' }, + ); + }, +}); + +const getCmd = defineCommand({ + meta: { name: 'get', description: 'Get a pack template with its items.' }, + args: { id: { type: 'positional', required: true, description: 'Template ID' } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client['pack-templates']({ templateId: args.id }).get(), { + action: 'get pack template', + resourceHint: `template ${args.id}`, + }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const createCmd = defineCommand({ + meta: { name: 'create', description: 'Create a pack template.' }, + args: { + name: { type: 'positional', required: true }, + category: { type: 'string', default: 'general' }, + description: { type: 'string', alias: 'd' }, + 'app-template': { type: 'boolean', default: false, description: 'Mark as app template (admin)' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const id = shortId('pt'); + const now = nowIso(); + const data = await runApi( + client['pack-templates'].post({ + id, + name: args.name, + description: args.description, + category: args.category, + isAppTemplate: args['app-template'], + localCreatedAt: now, + localUpdatedAt: now, + }), + { action: 'create pack template' }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const deleteCmd = defineCommand({ + meta: { name: 'delete', description: 'Delete a pack template.' }, + args: { id: { type: 'positional', required: true } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + await runApi(client['pack-templates']({ templateId: args.id }).delete(), { + action: 'delete pack template', + resourceHint: `template ${args.id}`, + }); + process.stdout.write(`Deleted ${args.id}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'templates', description: 'Pack templates.' }, + subCommands: { + list: () => Promise.resolve(listCmd), + get: () => Promise.resolve(getCmd), + create: () => Promise.resolve(createCmd), + delete: () => Promise.resolve(deleteCmd), + }, +}); diff --git a/packages/cli/src/commands/trails/index.ts b/packages/cli/src/commands/trails/index.ts new file mode 100644 index 0000000000..ad4cfc8d05 --- /dev/null +++ b/packages/cli/src/commands/trails/index.ts @@ -0,0 +1,89 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +const searchCmd = defineCommand({ + meta: { name: 'search', description: 'Search OSM trails by name, sport, or geography.' }, + args: { + q: { type: 'string', description: 'Text query' }, + lat: { type: 'string', description: 'Latitude (with --lon for spatial search)' }, + lon: { type: 'string', description: 'Longitude' }, + radius: { type: 'string', description: 'Search radius in km' }, + sport: { type: 'string' }, + limit: { type: 'string', default: '20' }, + offset: { type: 'string', default: '0' }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi( + client.trails.search.get({ + query: { + q: args.q, + lat: args.lat ? Number.parseFloat(args.lat) : undefined, + lon: args.lon ? Number.parseFloat(args.lon) : undefined, + radius: args.radius ? Number.parseFloat(args.radius) : undefined, + sport: args.sport, + limit: Number.parseInt(args.limit, 10), + offset: Number.parseInt(args.offset, 10), + }, + }), + { action: 'search trails' }, + ); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const trails = Array.isArray((data as Record).trails) + ? ((data as Record).trails as Record[]) + : []; + printTable( + trails.map((t) => ({ + osmId: t.osmId, + name: t.name, + sport: t.sport, + distance: t.distance, + })), + { title: 'Trails' }, + ); + }, +}); + +const getCmd = defineCommand({ + meta: { name: 'get', description: 'Get trail metadata by OSM ID.' }, + args: { id: { type: 'positional', required: true, description: 'OSM relation ID' } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const trail = await runApi(client.trails({ osmId: args.id }).get(), { + action: 'get trail', + resourceHint: `trail ${args.id}`, + }); + process.stdout.write(`${JSON.stringify(trail, null, 2)}\n`); + }, +}); + +const geometryCmd = defineCommand({ + meta: { name: 'geometry', description: 'Full GeoJSON geometry for a trail.' }, + args: { id: { type: 'positional', required: true, description: 'OSM relation ID' } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client.trails({ osmId: args.id }).geometry.get(), { + action: 'get trail geometry', + resourceHint: `trail ${args.id}`, + }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'trails', description: 'OSM trail search and details.' }, + subCommands: { + search: () => Promise.resolve(searchCmd), + get: () => Promise.resolve(getCmd), + geometry: () => Promise.resolve(geometryCmd), + }, +}); diff --git a/packages/cli/src/commands/trips/index.ts b/packages/cli/src/commands/trips/index.ts new file mode 100644 index 0000000000..f913c737ae --- /dev/null +++ b/packages/cli/src/commands/trips/index.ts @@ -0,0 +1,133 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { nowIso, shortId } from '../../api/ids'; +import { requireAuth, runApi } from '../../api/run'; +import { printSummary, printTable } from '../../shared'; + +const listCmd = defineCommand({ + meta: { name: 'list', description: 'List your trips.' }, + args: { json: { type: 'boolean', default: false } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const trips = await runApi(client.trips.get(), { action: 'list trips' }); + if (args.json) { + process.stdout.write(`${JSON.stringify(trips, null, 2)}\n`); + return; + } + const rows = Array.isArray(trips) ? trips : []; + printTable( + rows.map((t) => { + const r = t as Record; + return { + id: r.id, + name: r.name, + startDate: r.startDate, + endDate: r.endDate, + packId: r.packId, + }; + }), + { title: 'Your trips' }, + ); + }, +}); + +const getCmd = defineCommand({ + meta: { name: 'get', description: 'Get a trip by ID.' }, + args: { + id: { type: 'positional', required: true, description: 'Trip ID' }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const trip = await runApi(client.trips({ tripId: args.id }).get(), { + action: 'get trip', + resourceHint: `trip ${args.id}`, + }); + if (args.json) { + process.stdout.write(`${JSON.stringify(trip, null, 2)}\n`); + return; + } + const t = trip as Record; + printSummary( + { + id: t.id, + name: t.name, + description: t.description, + startDate: t.startDate, + endDate: t.endDate, + packId: t.packId, + notes: t.notes, + }, + `Trip ${t.name ?? args.id}`, + ); + }, +}); + +const createCmd = defineCommand({ + meta: { name: 'create', description: 'Create a new trip.' }, + args: { + name: { type: 'positional', required: true, description: 'Trip name' }, + description: { type: 'string', alias: 'd' }, + start: { type: 'string', description: 'ISO start date' }, + end: { type: 'string', description: 'ISO end date' }, + pack: { type: 'string', description: 'Optional pack ID to link' }, + notes: { type: 'string' }, + lat: { type: 'string' }, + lon: { type: 'string' }, + 'location-name': { type: 'string' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const id = shortId('t'); + const now = nowIso(); + const lat = args.lat ? Number.parseFloat(args.lat) : null; + const lon = args.lon ? Number.parseFloat(args.lon) : null; + const location = + lat != null && lon != null && !Number.isNaN(lat) && !Number.isNaN(lon) + ? { latitude: lat, longitude: lon, name: args['location-name'] } + : null; + const trip = await runApi( + client.trips.post({ + id, + name: args.name, + description: args.description, + location, + startDate: args.start, + endDate: args.end, + notes: args.notes, + packId: args.pack, + localCreatedAt: now, + localUpdatedAt: now, + }), + { action: 'create trip' }, + ); + process.stdout.write(`${JSON.stringify(trip, null, 2)}\n`); + }, +}); + +const deleteCmd = defineCommand({ + meta: { name: 'delete', description: 'Delete a trip.' }, + args: { id: { type: 'positional', required: true, description: 'Trip ID' } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + await runApi(client.trips({ tripId: args.id }).delete(), { + action: 'delete trip', + resourceHint: `trip ${args.id}`, + }); + process.stdout.write(`Deleted ${args.id}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'trips', description: 'List, create, and manage trips.' }, + subCommands: { + list: () => Promise.resolve(listCmd), + get: () => Promise.resolve(getCmd), + create: () => Promise.resolve(createCmd), + delete: () => Promise.resolve(deleteCmd), + }, +}); diff --git a/packages/cli/src/commands/user/index.ts b/packages/cli/src/commands/user/index.ts new file mode 100644 index 0000000000..368bbe7962 --- /dev/null +++ b/packages/cli/src/commands/user/index.ts @@ -0,0 +1,52 @@ +import { defineCommand } from 'citty'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi } from '../../api/run'; +import { printSummary } from '../../shared'; + +const getCmd = defineCommand({ + meta: { name: 'profile', description: 'Print the current user profile.' }, + async run() { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi(client.user.profile.get(), { action: 'get profile' }); + const r = data as Record; + printSummary( + { + firstName: r.firstName, + lastName: r.lastName, + email: r.email, + avatarUrl: r.avatarUrl, + }, + 'Profile', + ); + }, +}); + +const updateCmd = defineCommand({ + meta: { name: 'update', description: 'Update profile fields.' }, + args: { + 'first-name': { type: 'string' }, + 'last-name': { type: 'string' }, + email: { type: 'string' }, + avatar: { type: 'string', description: 'Avatar URL' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const body: Record = {}; + if (args['first-name']) body.firstName = args['first-name']; + if (args['last-name']) body.lastName = args['last-name']; + if (args.email) body.email = args.email; + if (args.avatar) body.avatarUrl = args.avatar; + const data = await runApi(client.user.profile.put(body), { action: 'update profile' }); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'user', description: 'View or update the signed-in user profile.' }, + subCommands: { + profile: () => Promise.resolve(getCmd), + update: () => Promise.resolve(updateCmd), + }, +}); diff --git a/packages/cli/src/commands/weather/index.ts b/packages/cli/src/commands/weather/index.ts new file mode 100644 index 0000000000..876cccb013 --- /dev/null +++ b/packages/cli/src/commands/weather/index.ts @@ -0,0 +1,53 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getUserClient } from '../../api/client'; +import { requireAuth, runApi, tryApi } from '../../api/run'; + +const forecastCmd = defineCommand({ + meta: { name: 'forecast', description: 'Get a 10-day forecast for a location (name or lat,lon).' }, + args: { + location: { type: 'positional', required: true, description: 'Location string' }, + }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const search = await tryApi(client.weather.search.get({ query: { q: args.location } })); + if (!search.ok) { + consola.error(`Could not search for "${args.location}" (HTTP ${search.status})`); + process.exit(1); + } + const first = Array.isArray(search.data) ? (search.data[0] as Record) : null; + const id = first && 'id' in first ? first.id : null; + if (id == null) { + consola.error(`No matching weather location for "${args.location}".`); + process.exit(1); + } + const forecast = await runApi( + client.weather.forecast.get({ query: { id: String(id) } }), + { action: 'get weather forecast' }, + ); + process.stdout.write(`${JSON.stringify(forecast, null, 2)}\n`); + }, +}); + +const searchCmd = defineCommand({ + meta: { name: 'search', description: 'Search weather locations by name.' }, + args: { q: { type: 'positional', required: true, description: 'Location query' } }, + async run({ args }) { + await requireAuth(); + const client = await getUserClient(); + const data = await runApi( + client.weather.search.get({ query: { q: args.q } }), + { action: 'search weather' }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +export default defineCommand({ + meta: { name: 'weather', description: 'Search weather locations + fetch forecasts.' }, + subCommands: { + forecast: () => Promise.resolve(forecastCmd), + search: () => Promise.resolve(searchCmd), + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 43ce9a9c7f..d800ace122 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,9 +1,8 @@ #!/usr/bin/env bun /** - * PackRat Analytics CLI — outdoor gear market intelligence. - * - * Built with citty (UnJS) for modern CLI ergonomics. + * PackRat CLI — analytics (DuckDB) plus a thin Eden Treaty wrapper around the + * PackRat API for user + admin operations. */ import { readFileSync } from 'node:fs'; @@ -39,48 +38,46 @@ const main = defineCommand({ meta: { name: 'packrat', version: getCliVersion(), - description: 'Outdoor gear analytics powered by DuckDB', + description: 'PackRat CLI — gear analytics + API client', }, subCommands: { - // Core search & discovery + // ── Session / API ────────────────────────────────────────────────────── + auth: () => import('./commands/auth').then((m) => m.default), + admin: () => import('./commands/admin').then((m) => m.default), + packs: () => import('./commands/packs').then((m) => m.default), + trips: () => import('./commands/trips').then((m) => m.default), + catalog: () => import('./commands/catalog').then((m) => m.default), + trails: () => import('./commands/trails').then((m) => m.default), + weather: () => import('./commands/weather').then((m) => m.default), + feed: () => import('./commands/feed').then((m) => m.default), + templates: () => import('./commands/templates').then((m) => m.default), + seasons: () => import('./commands/seasons').then((m) => m.default), + user: () => import('./commands/user').then((m) => m.default), + ai: () => import('./commands/ai').then((m) => m.default), + + // ── Local analytics (DuckDB-backed) ──────────────────────────────────── search: () => import('./commands/search').then((m) => m.default), compare: () => import('./commands/compare').then((m) => m.default), trends: () => import('./commands/trends').then((m) => m.default), brand: () => import('./commands/brand').then((m) => m.default), category: () => import('./commands/category').then((m) => m.default), deals: () => import('./commands/deals').then((m) => m.default), - - // Ratings & weight sales: () => import('./commands/sales').then((m) => m.default), ratings: () => import('./commands/ratings').then((m) => m.default), lightweight: () => import('./commands/lightweight').then((m) => m.default), - - // Dashboards stats: () => import('./commands/stats').then((m) => m.default), summary: () => import('./commands/summary').then((m) => m.default), brands: () => import('./commands/brands').then((m) => m.default), prices: () => import('./commands/prices').then((m) => m.default), - - // Data management cache: () => import('./commands/cache').then((m) => m.default), - - // Specs specs: () => import('./commands/specs').then((m) => m.default), 'build-specs': () => import('./commands/build-specs').then((m) => m.default), filter: () => import('./commands/filter').then((m) => m.default), - - // Advanced analytics 'market-share': () => import('./commands/market-share').then((m) => m.default), - - // Enrichment & dedup resolve: () => import('./commands/resolve').then((m) => m.default), reviews: () => import('./commands/reviews').then((m) => m.default), images: () => import('./commands/images').then((m) => m.default), - - // Schema analysis schema: () => import('./commands/schema').then((m) => m.default), - - // Export export: () => import('./commands/export').then((m) => m.default), }, }); From 4b0446ae07bb2a57d43caad31f932563221f426c Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 13:03:46 -0600 Subject: [PATCH 03/30] =?UTF-8?q?=F0=9F=9B=82=20cli:=20admin=20command=20t?= =?UTF-8?q?ree=20(stats,=20users,=20packs,=20catalog,=20trails,=20analytic?= =?UTF-8?q?s,=20ETL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the API's admin surface. Every command goes through requireAdmin() which checks the stored JWT (and its visible expiry) before hitting the admin Treaty client. Confirmations gate destructive ops; --json escape hatch for piping into jq. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/admin/analytics.ts | 146 +++++++++++++++++++ packages/cli/src/commands/admin/catalog.ts | 102 +++++++++++++ packages/cli/src/commands/admin/etl.ts | 93 ++++++++++++ packages/cli/src/commands/admin/index.ts | 16 ++ packages/cli/src/commands/admin/packs.ts | 75 ++++++++++ packages/cli/src/commands/admin/stats.ts | 17 +++ packages/cli/src/commands/admin/trails.ts | 102 +++++++++++++ packages/cli/src/commands/admin/users.ts | 84 +++++++++++ 8 files changed, 635 insertions(+) create mode 100644 packages/cli/src/commands/admin/analytics.ts create mode 100644 packages/cli/src/commands/admin/catalog.ts create mode 100644 packages/cli/src/commands/admin/etl.ts create mode 100644 packages/cli/src/commands/admin/index.ts create mode 100644 packages/cli/src/commands/admin/packs.ts create mode 100644 packages/cli/src/commands/admin/stats.ts create mode 100644 packages/cli/src/commands/admin/trails.ts create mode 100644 packages/cli/src/commands/admin/users.ts diff --git a/packages/cli/src/commands/admin/analytics.ts b/packages/cli/src/commands/admin/analytics.ts new file mode 100644 index 0000000000..3960a0ac69 --- /dev/null +++ b/packages/cli/src/commands/admin/analytics.ts @@ -0,0 +1,146 @@ +import { defineCommand } from 'citty'; +import { getAdminClient } from '../../api/client'; +import { requireAdmin, runApi } from '../../api/run'; + +function dump(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +const growthCmd = defineCommand({ + meta: { name: 'growth', description: 'Platform growth metrics.' }, + args: { + period: { type: 'string', description: 'day | week | month' }, + range: { type: 'string', description: 'Numeric range' }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.analytics.platform.growth.get({ + query: { + period: args.period as 'day' | 'week' | 'month' | undefined, + range: args.range ? Number.parseInt(args.range, 10) : undefined, + }, + }), + { action: 'admin growth analytics', requiresAdmin: true }, + ); + dump(data); + }, +}); + +const activityCmd = defineCommand({ + meta: { name: 'activity', description: 'Platform activity metrics.' }, + args: { + period: { type: 'string' }, + range: { type: 'string' }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.analytics.platform.activity.get({ + query: { + period: args.period as 'day' | 'week' | 'month' | undefined, + range: args.range ? Number.parseInt(args.range, 10) : undefined, + }, + }), + { action: 'admin activity analytics', requiresAdmin: true }, + ); + dump(data); + }, +}); + +const activeUsersCmd = defineCommand({ + meta: { name: 'active-users', description: 'DAU / WAU / MAU.' }, + async run() { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi(client.admin.analytics.platform['active-users'].get(), { + action: 'admin active users', + requiresAdmin: true, + }); + dump(data); + }, +}); + +const breakdownCmd = defineCommand({ + meta: { name: 'breakdown', description: 'Packs by category distribution.' }, + async run() { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi(client.admin.analytics.platform.breakdown.get(), { + action: 'admin breakdown', + requiresAdmin: true, + }); + dump(data); + }, +}); + +const catalogOverviewCmd = defineCommand({ + meta: { name: 'catalog-overview', description: 'Catalog-wide overview.' }, + async run() { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi(client.admin.analytics.catalog.overview.get(), { + action: 'admin catalog overview', + requiresAdmin: true, + }); + dump(data); + }, +}); + +const brandsCmd = defineCommand({ + meta: { name: 'top-brands', description: 'Top gear brands.' }, + args: { limit: { type: 'string', default: '20' } }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.analytics.catalog.brands.get({ + query: { limit: Number.parseInt(args.limit, 10) }, + }), + { action: 'admin top brands', requiresAdmin: true }, + ); + dump(data); + }, +}); + +const pricesCmd = defineCommand({ + meta: { name: 'prices', description: 'Catalog price distribution.' }, + async run() { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi(client.admin.analytics.catalog.prices.get(), { + action: 'admin price distribution', + requiresAdmin: true, + }); + dump(data); + }, +}); + +const embeddingsCmd = defineCommand({ + meta: { name: 'embeddings', description: 'Embedding coverage stats.' }, + async run() { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi(client.admin.analytics.catalog.embeddings.get(), { + action: 'admin embedding stats', + requiresAdmin: true, + }); + dump(data); + }, +}); + +export default defineCommand({ + meta: { name: 'analytics', description: 'Admin analytics dashboards.' }, + subCommands: { + growth: () => Promise.resolve(growthCmd), + activity: () => Promise.resolve(activityCmd), + 'active-users': () => Promise.resolve(activeUsersCmd), + breakdown: () => Promise.resolve(breakdownCmd), + 'catalog-overview': () => Promise.resolve(catalogOverviewCmd), + 'top-brands': () => Promise.resolve(brandsCmd), + prices: () => Promise.resolve(pricesCmd), + embeddings: () => Promise.resolve(embeddingsCmd), + }, +}); diff --git a/packages/cli/src/commands/admin/catalog.ts b/packages/cli/src/commands/admin/catalog.ts new file mode 100644 index 0000000000..d54192e2d9 --- /dev/null +++ b/packages/cli/src/commands/admin/catalog.ts @@ -0,0 +1,102 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getAdminClient } from '../../api/client'; +import { requireAdmin, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +const listCmd = defineCommand({ + meta: { name: 'list', description: 'Search/list catalog items (admin view).' }, + args: { + q: { type: 'string' }, + limit: { type: 'string', default: '50' }, + offset: { type: 'string', default: '0' }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin['catalog-list'].get({ + query: { + q: args.q, + limit: Number.parseInt(args.limit, 10), + offset: Number.parseInt(args.offset, 10), + }, + }), + { action: 'admin list catalog', requiresAdmin: true }, + ); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const items = Array.isArray(data) ? (data as Record[]) : []; + printTable( + items.map((it) => ({ + id: it.id, + name: typeof it.name === 'string' ? it.name.slice(0, 60) : it.name, + brand: it.brand, + weight: it.weight, + price: it.price, + })), + { title: 'Catalog (admin)' }, + ); + }, +}); + +const updateCmd = defineCommand({ + meta: { name: 'update', description: 'Update a catalog item (admin).' }, + args: { + id: { type: 'positional', required: true, description: 'Catalog item ID' }, + name: { type: 'string' }, + brand: { type: 'string' }, + description: { type: 'string' }, + weight: { type: 'string', description: 'Weight (numeric)' }, + 'weight-unit': { type: 'string', description: 'g | oz | kg | lb' }, + price: { type: 'string', description: 'Price (numeric)' }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const body: Record = {}; + if (args.name) body.name = args.name; + if (args.brand) body.brand = args.brand; + if (args.description) body.description = args.description; + if (args.weight) body.weight = Number.parseFloat(args.weight); + if (args['weight-unit']) body.weightUnit = args['weight-unit']; + if (args.price) body.price = Number.parseFloat(args.price); + await runApi(client.admin.catalog({ id: args.id }).patch(body), { + action: 'admin update catalog item', + resourceHint: `item ${args.id}`, + requiresAdmin: true, + }); + consola.success(`Updated ${args.id}.`); + }, +}); + +const deleteCmd = defineCommand({ + meta: { name: 'delete', description: 'Delete a catalog item (admin).' }, + args: { id: { type: 'positional', required: true }, yes: { type: 'boolean', alias: 'y', default: false } }, + async run({ args }) { + await requireAdmin(); + if (!args.yes) { + const confirm = await consola.prompt(`Delete catalog item ${args.id}?`, { type: 'confirm' }); + if (!confirm) return consola.info('Aborted.'); + } + const client = await getAdminClient(); + await runApi(client.admin.catalog({ id: args.id }).delete(), { + action: 'admin delete catalog item', + resourceHint: `item ${args.id}`, + requiresAdmin: true, + }); + consola.success(`Deleted ${args.id}.`); + }, +}); + +export default defineCommand({ + meta: { name: 'catalog', description: 'Admin catalog ops.' }, + subCommands: { + list: () => Promise.resolve(listCmd), + update: () => Promise.resolve(updateCmd), + delete: () => Promise.resolve(deleteCmd), + }, +}); diff --git a/packages/cli/src/commands/admin/etl.ts b/packages/cli/src/commands/admin/etl.ts new file mode 100644 index 0000000000..a6adedd470 --- /dev/null +++ b/packages/cli/src/commands/admin/etl.ts @@ -0,0 +1,93 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getAdminClient } from '../../api/client'; +import { requireAdmin, runApi } from '../../api/run'; + +const listCmd = defineCommand({ + meta: { name: 'list', description: 'List recent ETL jobs.' }, + args: { limit: { type: 'string', default: '20' } }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.analytics.catalog.etl.get({ + query: { limit: Number.parseInt(args.limit, 10) }, + }), + { action: 'admin list ETL jobs', requiresAdmin: true }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const failureSummaryCmd = defineCommand({ + meta: { name: 'failure-summary', description: 'Top recent failure patterns.' }, + args: { limit: { type: 'string', default: '10' } }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.analytics.catalog.etl['failure-summary'].get({ + query: { limit: Number.parseInt(args.limit, 10) }, + }), + { action: 'admin ETL failure summary', requiresAdmin: true }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const jobFailuresCmd = defineCommand({ + meta: { name: 'job-failures', description: 'Per-job failure drill-down.' }, + args: { + id: { type: 'positional', required: true, description: 'ETL job ID' }, + limit: { type: 'string', default: '50' }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.analytics.catalog.etl({ jobId: args.id }).failures.get({ + query: { limit: Number.parseInt(args.limit, 10) }, + }), + { action: 'admin ETL job failures', resourceHint: `job ${args.id}`, requiresAdmin: true }, + ); + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + }, +}); + +const resetStuckCmd = defineCommand({ + meta: { name: 'reset-stuck', description: 'Mark stuck-running jobs as failed.' }, + async run() { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi(client.admin.analytics.catalog.etl['reset-stuck'].post({}), { + action: 'admin reset stuck ETL', + requiresAdmin: true, + }); + consola.success(`Done: ${JSON.stringify(data)}`); + }, +}); + +const retryCmd = defineCommand({ + meta: { name: 'retry', description: 'Retry a failed ETL job.' }, + args: { id: { type: 'positional', required: true, description: 'ETL job ID' } }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.analytics.catalog.etl({ jobId: args.id }).retry.post({}), + { action: 'admin retry ETL job', resourceHint: `job ${args.id}`, requiresAdmin: true }, + ); + consola.success(`Retried: ${JSON.stringify(data)}`); + }, +}); + +export default defineCommand({ + meta: { name: 'etl', description: 'Admin ETL operations.' }, + subCommands: { + list: () => Promise.resolve(listCmd), + 'failure-summary': () => Promise.resolve(failureSummaryCmd), + 'job-failures': () => Promise.resolve(jobFailuresCmd), + 'reset-stuck': () => Promise.resolve(resetStuckCmd), + retry: () => Promise.resolve(retryCmd), + }, +}); diff --git a/packages/cli/src/commands/admin/index.ts b/packages/cli/src/commands/admin/index.ts new file mode 100644 index 0000000000..26fb68f1ee --- /dev/null +++ b/packages/cli/src/commands/admin/index.ts @@ -0,0 +1,16 @@ +import { defineCommand } from 'citty'; + +export default defineCommand({ + meta: { name: 'admin', description: 'Admin-only PackRat operations (requires admin JWT).' }, + subCommands: { + login: () => import('./login').then((m) => m.default), + logout: () => import('./logout').then((m) => m.default), + stats: () => import('./stats').then((m) => m.default), + users: () => import('./users').then((m) => m.default), + packs: () => import('./packs').then((m) => m.default), + catalog: () => import('./catalog').then((m) => m.default), + trails: () => import('./trails').then((m) => m.default), + analytics: () => import('./analytics').then((m) => m.default), + etl: () => import('./etl').then((m) => m.default), + }, +}); diff --git a/packages/cli/src/commands/admin/packs.ts b/packages/cli/src/commands/admin/packs.ts new file mode 100644 index 0000000000..41a472fa69 --- /dev/null +++ b/packages/cli/src/commands/admin/packs.ts @@ -0,0 +1,75 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getAdminClient } from '../../api/client'; +import { requireAdmin, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +const listCmd = defineCommand({ + meta: { name: 'list', description: 'Search/list packs across all users.' }, + args: { + q: { type: 'string' }, + limit: { type: 'string', default: '50' }, + offset: { type: 'string', default: '0' }, + 'include-deleted': { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin['packs-list'].get({ + query: { + q: args.q, + limit: Number.parseInt(args.limit, 10), + offset: Number.parseInt(args.offset, 10), + includeDeleted: args['include-deleted'] ? 1 : 0, + }, + }), + { action: 'admin list packs', requiresAdmin: true }, + ); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const items = Array.isArray(data) ? (data as Record[]) : []; + printTable( + items.map((p) => ({ + id: p.id, + name: p.name, + userId: p.userId, + deleted: p.deleted, + })), + { title: 'Packs (admin)' }, + ); + }, +}); + +const deleteCmd = defineCommand({ + meta: { name: 'delete', description: 'Soft-delete any pack (admin).' }, + args: { + id: { type: 'positional', required: true, description: 'Pack ID' }, + yes: { type: 'boolean', alias: 'y', default: false }, + }, + async run({ args }) { + await requireAdmin(); + if (!args.yes) { + const confirm = await consola.prompt(`Delete pack ${args.id}?`, { type: 'confirm' }); + if (!confirm) return consola.info('Aborted.'); + } + const client = await getAdminClient(); + await runApi(client.admin.packs({ id: args.id }).delete(), { + action: 'admin delete pack', + resourceHint: `pack ${args.id}`, + requiresAdmin: true, + }); + consola.success(`Deleted ${args.id}.`); + }, +}); + +export default defineCommand({ + meta: { name: 'packs', description: 'Admin pack ops.' }, + subCommands: { + list: () => Promise.resolve(listCmd), + delete: () => Promise.resolve(deleteCmd), + }, +}); diff --git a/packages/cli/src/commands/admin/stats.ts b/packages/cli/src/commands/admin/stats.ts new file mode 100644 index 0000000000..0f446d78fe --- /dev/null +++ b/packages/cli/src/commands/admin/stats.ts @@ -0,0 +1,17 @@ +import { defineCommand } from 'citty'; +import { getAdminClient } from '../../api/client'; +import { requireAdmin, runApi } from '../../api/run'; +import { printSummary } from '../../shared'; + +export default defineCommand({ + meta: { name: 'stats', description: 'High-level platform stats.' }, + async run() { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi(client.admin.stats.get(), { + action: 'admin stats', + requiresAdmin: true, + }); + printSummary(data as Record, 'Admin stats'); + }, +}); diff --git a/packages/cli/src/commands/admin/trails.ts b/packages/cli/src/commands/admin/trails.ts new file mode 100644 index 0000000000..43072f3be6 --- /dev/null +++ b/packages/cli/src/commands/admin/trails.ts @@ -0,0 +1,102 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getAdminClient } from '../../api/client'; +import { requireAdmin, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +const searchCmd = defineCommand({ + meta: { name: 'search', description: 'Admin trail search.' }, + args: { + q: { type: 'positional', required: true }, + sport: { type: 'string' }, + limit: { type: 'string', default: '50' }, + offset: { type: 'string', default: '0' }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.trails.search.get({ + query: { + q: args.q, + sport: args.sport, + limit: Number.parseInt(args.limit, 10), + offset: Number.parseInt(args.offset, 10), + }, + }), + { action: 'admin search trails', requiresAdmin: true }, + ); + const trails = Array.isArray((data as Record).trails) + ? ((data as Record).trails as Record[]) + : []; + printTable( + trails.map((t) => ({ osmId: t.osmId, name: t.name, sport: t.sport })), + { title: 'Trails (admin)' }, + ); + }, +}); + +const reportsCmd = defineCommand({ + meta: { name: 'reports', description: 'List trail condition reports (admin).' }, + args: { + q: { type: 'string' }, + limit: { type: 'string', default: '50' }, + offset: { type: 'string', default: '0' }, + 'include-deleted': { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin.trails.conditions.get({ + query: { + q: args.q, + limit: Number.parseInt(args.limit, 10), + offset: Number.parseInt(args.offset, 10), + includeDeleted: args['include-deleted'] ? 1 : 0, + }, + }), + { action: 'admin list trail reports', requiresAdmin: true }, + ); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const rows = Array.isArray(data) ? (data as Record[]) : []; + printTable( + rows.map((r) => ({ + id: r.id, + trailName: r.trailName, + condition: r.overallCondition, + userId: r.userId, + deleted: r.deleted, + })), + { title: 'Trail reports (admin)' }, + ); + }, +}); + +const deleteReportCmd = defineCommand({ + meta: { name: 'delete-report', description: 'Soft-delete a trail condition report (admin).' }, + args: { id: { type: 'positional', required: true } }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + await runApi(client.admin.trails.conditions({ reportId: args.id }).delete(), { + action: 'admin delete trail report', + resourceHint: `report ${args.id}`, + requiresAdmin: true, + }); + consola.success(`Deleted ${args.id}.`); + }, +}); + +export default defineCommand({ + meta: { name: 'trails', description: 'Admin trail / trail-condition ops.' }, + subCommands: { + search: () => Promise.resolve(searchCmd), + reports: () => Promise.resolve(reportsCmd), + 'delete-report': () => Promise.resolve(deleteReportCmd), + }, +}); diff --git a/packages/cli/src/commands/admin/users.ts b/packages/cli/src/commands/admin/users.ts new file mode 100644 index 0000000000..2818cb22d6 --- /dev/null +++ b/packages/cli/src/commands/admin/users.ts @@ -0,0 +1,84 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { getAdminClient } from '../../api/client'; +import { requireAdmin, runApi } from '../../api/run'; +import { printTable } from '../../shared'; + +const listCmd = defineCommand({ + meta: { name: 'list', description: 'Search/list users.' }, + args: { + q: { type: 'string', description: 'Email/name filter' }, + limit: { type: 'string', default: '50' }, + offset: { type: 'string', default: '0' }, + json: { type: 'boolean', default: false }, + }, + async run({ args }) { + await requireAdmin(); + const client = await getAdminClient(); + const data = await runApi( + client.admin['users-list'].get({ + query: { + q: args.q, + limit: Number.parseInt(args.limit, 10), + offset: Number.parseInt(args.offset, 10), + }, + }), + { action: 'admin list users', requiresAdmin: true }, + ); + if (args.json) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + const items = Array.isArray((data as Record).items) + ? ((data as Record).items as Record[]) + : Array.isArray(data) + ? (data as Record[]) + : []; + printTable( + items.map((u) => ({ + id: u.id, + email: u.email, + name: u.name ?? u.firstName, + createdAt: u.createdAt, + })), + { title: 'Users' }, + ); + }, +}); + +const hardDeleteCmd = defineCommand({ + meta: { name: 'hard-delete', description: 'GDPR-style hard delete (irreversible).' }, + args: { + id: { type: 'positional', required: true, description: 'User ID' }, + reason: { type: 'string', required: true, description: 'Audit reason (required)' }, + yes: { type: 'boolean', alias: 'y', default: false }, + }, + async run({ args }) { + await requireAdmin(); + if (!args.yes) { + const confirm = await consola.prompt( + `Hard-delete user ${args.id}? This is irreversible.`, + { type: 'confirm' }, + ); + if (!confirm) { + consola.info('Aborted.'); + return; + } + } + const client = await getAdminClient(); + await runApi(client.admin.users({ id: args.id }).hard.delete({ reason: args.reason }), { + action: 'hard delete user', + resourceHint: `user ${args.id}`, + requiresAdmin: true, + }); + consola.success(`Hard-deleted ${args.id}.`); + }, +}); + +export default defineCommand({ + meta: { name: 'users', description: 'Admin user operations.' }, + subCommands: { + list: () => Promise.resolve(listCmd), + 'hard-delete': () => Promise.resolve(hardDeleteCmd), + }, +}); From 4f1d25ea052b32497fae1c4193ebf49019cf0525 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 13:20:21 -0600 Subject: [PATCH 04/30] =?UTF-8?q?=F0=9F=A9=B9=20mcp,cli:=20biome=20auto-fi?= =?UTF-8?q?xes=20(formatting,=20useMaxParams,=20import=20order)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group printError/formatError/settle args into an options object so they fit biome's useMaxParams rule, and let biome reorganize imports and collapse a few short multi-line forms. Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 5 +- packages/cli/src/api/run.ts | 51 ++++++++++++------- packages/cli/src/commands/admin/analytics.ts | 8 +-- packages/cli/src/commands/admin/catalog.ts | 5 +- packages/cli/src/commands/admin/packs.ts | 2 +- packages/cli/src/commands/admin/trails.ts | 2 +- packages/cli/src/commands/admin/users.ts | 7 ++- packages/cli/src/commands/ai/index.ts | 5 +- packages/cli/src/commands/catalog/index.ts | 6 +-- packages/cli/src/commands/feed/index.ts | 2 +- .../cli/src/commands/packs/gap-analysis.ts | 2 +- packages/cli/src/commands/templates/index.ts | 6 ++- packages/cli/src/commands/weather/index.ts | 19 +++---- packages/cli/tsconfig.json | 11 +++- packages/mcp/src/client.ts | 5 +- packages/mcp/src/index.ts | 2 +- packages/mcp/src/resources.ts | 50 +++++++++++------- packages/mcp/src/tools/admin.ts | 46 ++++++++--------- packages/mcp/src/tools/ai.ts | 3 +- packages/mcp/src/tools/feed.ts | 16 ++---- packages/mcp/src/tools/packTemplates.ts | 3 +- packages/mcp/src/tools/packs.ts | 3 +- packages/mcp/src/tools/trail-conditions.ts | 8 +-- packages/mcp/src/tools/trails.ts | 9 ++-- packages/mcp/src/tools/upload.ts | 6 ++- packages/mcp/src/tools/user.ts | 4 +- 26 files changed, 167 insertions(+), 119 deletions(-) diff --git a/bun.lock b/bun.lock index d2ca53ff96..a1a067c055 100644 --- a/bun.lock +++ b/bun.lock @@ -279,7 +279,7 @@ "postcss-import": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", - "vitest": "~3.1.4", + "vitest": "catalog:", }, }, "apps/landing": { @@ -347,7 +347,7 @@ "postcss-import": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", - "vitest": "~3.1.4", + "vitest": "catalog:", }, }, "apps/trails": { @@ -547,6 +547,7 @@ "dependencies": { "@duckdb/node-api": "catalog:", "@packrat/analytics": "workspace:*", + "@packrat/api-client": "workspace:*", "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", "chalk": "catalog:", diff --git a/packages/cli/src/api/run.ts b/packages/cli/src/api/run.ts index 99c499af91..3721ad1b3d 100644 --- a/packages/cli/src/api/run.ts +++ b/packages/cli/src/api/run.ts @@ -9,9 +9,18 @@ import chalk from 'chalk'; import consola from 'consola'; import { loadConfig } from './config'; -export type TreatyResponse = { - data: T | null; - error: { status: number; value: unknown } | null; +/** + * Treaty's actual return shape is a discriminated union: + * `({ data: T; error: null } | { data: null; error: EdenFetchError })` + * `& { status; response; headers }`. + * + * We don't try to recreate that here — the helpers below accept anything that + * structurally looks like a Treaty result and operate on `data`/`error`/`status` + * with light runtime checks. Callers get back the success-branch `data`. + */ +export type TreatyLike = { + data: unknown; + error: unknown; status: number; }; @@ -28,39 +37,46 @@ export type RunOptions = { * Await a Treaty call, return `data` on success, or print a friendly error and * `process.exit(1)`. Never returns null. */ -export async function runApi( - promise: Promise>, +export async function runApi( + promise: Promise, opts: RunOptions, -): Promise { +): Promise> { const result = await promise; if (result.error || result.data == null) { - printError(result.status, result.error?.value, opts); + printError({ status: result.status, body: errorValue(result.error), opts }); process.exit(1); } - return result.data; + return result.data as NonNullable; // safe-cast: validated above } /** * Variant that does NOT exit on error — returns a discriminated union. Useful * when the command wants to react to a failure (e.g. retry, fallback). */ -export async function tryApi( - promise: Promise>, -): Promise<{ ok: true; data: T } | { ok: false; status: number; value: unknown }> { +export async function tryApi( + promise: Promise, +): Promise< + { ok: true; data: NonNullable } | { ok: false; status: number; value: unknown } +> { const result = await promise; if (result.error || result.data == null) { - return { ok: false, status: result.status, value: result.error?.value ?? null }; + return { ok: false, status: result.status, value: errorValue(result.error) }; } - return { ok: true, data: result.data }; + return { ok: true, data: result.data as NonNullable }; +} + +function errorValue(error: unknown): unknown { + if (error && typeof error === 'object' && 'value' in error) { + return (error as { value: unknown }).value; + } + return error; } /** Confirm a user is signed in; exit with a helpful hint if not. */ export async function requireAuth(): Promise { const config = await loadConfig(); if (!config.accessToken) { - consola.error( - `Not signed in. Run ${chalk.cyan('packrat auth login')} to authenticate first.`, - ); + consola.error(`Not signed in. Run ${chalk.cyan('packrat auth login')} to authenticate first.`); process.exit(1); } } @@ -82,7 +98,8 @@ export async function requireAdmin(): Promise { } } -function printError(status: number, body: unknown, opts: RunOptions): void { +function printError(args: { status: number; body: unknown; opts: RunOptions }): void { + const { status, body, opts } = args; const action = opts.action; const resource = opts.resourceHint ? ` (${opts.resourceHint})` : ''; const detail = extractMessage(body); diff --git a/packages/cli/src/commands/admin/analytics.ts b/packages/cli/src/commands/admin/analytics.ts index 3960a0ac69..ea19c8a095 100644 --- a/packages/cli/src/commands/admin/analytics.ts +++ b/packages/cli/src/commands/admin/analytics.ts @@ -18,8 +18,8 @@ const growthCmd = defineCommand({ const data = await runApi( client.admin.analytics.platform.growth.get({ query: { - period: args.period as 'day' | 'week' | 'month' | undefined, - range: args.range ? Number.parseInt(args.range, 10) : undefined, + period: (args.period ?? 'month') as 'day' | 'week' | 'month', + range: args.range ? Number.parseInt(args.range, 10) : 12, }, }), { action: 'admin growth analytics', requiresAdmin: true }, @@ -40,8 +40,8 @@ const activityCmd = defineCommand({ const data = await runApi( client.admin.analytics.platform.activity.get({ query: { - period: args.period as 'day' | 'week' | 'month' | undefined, - range: args.range ? Number.parseInt(args.range, 10) : undefined, + period: (args.period ?? 'month') as 'day' | 'week' | 'month', + range: args.range ? Number.parseInt(args.range, 10) : 12, }, }), { action: 'admin activity analytics', requiresAdmin: true }, diff --git a/packages/cli/src/commands/admin/catalog.ts b/packages/cli/src/commands/admin/catalog.ts index d54192e2d9..2bafbd6d5e 100644 --- a/packages/cli/src/commands/admin/catalog.ts +++ b/packages/cli/src/commands/admin/catalog.ts @@ -75,7 +75,10 @@ const updateCmd = defineCommand({ const deleteCmd = defineCommand({ meta: { name: 'delete', description: 'Delete a catalog item (admin).' }, - args: { id: { type: 'positional', required: true }, yes: { type: 'boolean', alias: 'y', default: false } }, + args: { + id: { type: 'positional', required: true }, + yes: { type: 'boolean', alias: 'y', default: false }, + }, async run({ args }) { await requireAdmin(); if (!args.yes) { diff --git a/packages/cli/src/commands/admin/packs.ts b/packages/cli/src/commands/admin/packs.ts index 41a472fa69..c4eabb5ec5 100644 --- a/packages/cli/src/commands/admin/packs.ts +++ b/packages/cli/src/commands/admin/packs.ts @@ -22,7 +22,7 @@ const listCmd = defineCommand({ q: args.q, limit: Number.parseInt(args.limit, 10), offset: Number.parseInt(args.offset, 10), - includeDeleted: args['include-deleted'] ? 1 : 0, + includeDeleted: args['include-deleted'] ? '1' : '0', }, }), { action: 'admin list packs', requiresAdmin: true }, diff --git a/packages/cli/src/commands/admin/trails.ts b/packages/cli/src/commands/admin/trails.ts index 43072f3be6..c60cd3ecdc 100644 --- a/packages/cli/src/commands/admin/trails.ts +++ b/packages/cli/src/commands/admin/trails.ts @@ -54,7 +54,7 @@ const reportsCmd = defineCommand({ q: args.q, limit: Number.parseInt(args.limit, 10), offset: Number.parseInt(args.offset, 10), - includeDeleted: args['include-deleted'] ? 1 : 0, + includeDeleted: args['include-deleted'] ? '1' : '0', }, }), { action: 'admin list trail reports', requiresAdmin: true }, diff --git a/packages/cli/src/commands/admin/users.ts b/packages/cli/src/commands/admin/users.ts index 2818cb22d6..7b015264c0 100644 --- a/packages/cli/src/commands/admin/users.ts +++ b/packages/cli/src/commands/admin/users.ts @@ -56,10 +56,9 @@ const hardDeleteCmd = defineCommand({ async run({ args }) { await requireAdmin(); if (!args.yes) { - const confirm = await consola.prompt( - `Hard-delete user ${args.id}? This is irreversible.`, - { type: 'confirm' }, - ); + const confirm = await consola.prompt(`Hard-delete user ${args.id}? This is irreversible.`, { + type: 'confirm', + }); if (!confirm) { consola.info('Aborted.'); return; diff --git a/packages/cli/src/commands/ai/index.ts b/packages/cli/src/commands/ai/index.ts index eddae9fb4c..dc9d9cd9ac 100644 --- a/packages/cli/src/commands/ai/index.ts +++ b/packages/cli/src/commands/ai/index.ts @@ -65,7 +65,10 @@ const schemaCmd = defineCommand({ }); export default defineCommand({ - meta: { name: 'ai', description: 'AI / RAG / SQL / web-search helpers (renamed from analytics SQL).' }, + meta: { + name: 'ai', + description: 'AI / RAG / SQL / web-search helpers (renamed from analytics SQL).', + }, subCommands: { rag: () => Promise.resolve(ragCmd), web: () => Promise.resolve(webCmd), diff --git a/packages/cli/src/commands/catalog/index.ts b/packages/cli/src/commands/catalog/index.ts index e1e9f5f0fb..0eeb99cf58 100644 --- a/packages/cli/src/commands/catalog/index.ts +++ b/packages/cli/src/commands/catalog/index.ts @@ -19,7 +19,7 @@ const searchCmd = defineCommand({ const page = Number.parseInt(args.page, 10); const data = await runApi( client.catalog.get({ - query: { q: args.q, category: args.category, limit, page }, + query: { q: args.q, category: args.category, limit, page, sort: undefined }, }), { action: 'search catalog' }, ); @@ -56,7 +56,7 @@ const semanticCmd = defineCommand({ const client = await getUserClient(); const limit = Number.parseInt(args.limit, 10); const data = await runApi( - client.catalog['vector-search'].get({ query: { q: args.q, limit } }), + client.catalog['vector-search'].get({ query: { q: args.q, limit, offset: 0 } }), { action: 'semantic catalog search' }, ); if (args.json) { @@ -117,7 +117,7 @@ const categoriesCmd = defineCommand({ async run() { await requireAuth(); const client = await getUserClient(); - const data = await runApi(client.catalog.categories.get({ query: {} }), { + const data = await runApi(client.catalog.categories.get({ query: { limit: 50 } }), { action: 'list catalog categories', }); process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); diff --git a/packages/cli/src/commands/feed/index.ts b/packages/cli/src/commands/feed/index.ts index 4ea5ea5e64..cdab7cd018 100644 --- a/packages/cli/src/commands/feed/index.ts +++ b/packages/cli/src/commands/feed/index.ts @@ -70,7 +70,7 @@ const commentCmd = defineCommand({ const data = await runApi( client.feed({ postId: args.id }).comments.post({ content: args.content, - parentCommentId: args.parent, + parentCommentId: args.parent ? Number.parseInt(args.parent, 10) : undefined, }), { action: 'create feed comment', resourceHint: `post ${args.id}` }, ); diff --git a/packages/cli/src/commands/packs/gap-analysis.ts b/packages/cli/src/commands/packs/gap-analysis.ts index d4916eeada..bbeceeac49 100644 --- a/packages/cli/src/commands/packs/gap-analysis.ts +++ b/packages/cli/src/commands/packs/gap-analysis.ts @@ -30,7 +30,7 @@ export default defineCommand({ client.packs({ packId: args.id })['gap-analysis'].post({ destination: args.destination, tripType: args['trip-type'], - duration: Number.parseInt(args.duration, 10), + duration: args.duration, startDate: args.start, endDate: args.end, }), diff --git a/packages/cli/src/commands/templates/index.ts b/packages/cli/src/commands/templates/index.ts index 30e8df3889..47b797fd63 100644 --- a/packages/cli/src/commands/templates/index.ts +++ b/packages/cli/src/commands/templates/index.ts @@ -51,7 +51,11 @@ const createCmd = defineCommand({ name: { type: 'positional', required: true }, category: { type: 'string', default: 'general' }, description: { type: 'string', alias: 'd' }, - 'app-template': { type: 'boolean', default: false, description: 'Mark as app template (admin)' }, + 'app-template': { + type: 'boolean', + default: false, + description: 'Mark as app template (admin)', + }, }, async run({ args }) { await requireAuth(); diff --git a/packages/cli/src/commands/weather/index.ts b/packages/cli/src/commands/weather/index.ts index 876cccb013..6d854c8024 100644 --- a/packages/cli/src/commands/weather/index.ts +++ b/packages/cli/src/commands/weather/index.ts @@ -4,7 +4,10 @@ import { getUserClient } from '../../api/client'; import { requireAuth, runApi, tryApi } from '../../api/run'; const forecastCmd = defineCommand({ - meta: { name: 'forecast', description: 'Get a 10-day forecast for a location (name or lat,lon).' }, + meta: { + name: 'forecast', + description: 'Get a 10-day forecast for a location (name or lat,lon).', + }, args: { location: { type: 'positional', required: true, description: 'Location string' }, }, @@ -22,10 +25,9 @@ const forecastCmd = defineCommand({ consola.error(`No matching weather location for "${args.location}".`); process.exit(1); } - const forecast = await runApi( - client.weather.forecast.get({ query: { id: String(id) } }), - { action: 'get weather forecast' }, - ); + const forecast = await runApi(client.weather.forecast.get({ query: { id: String(id) } }), { + action: 'get weather forecast', + }); process.stdout.write(`${JSON.stringify(forecast, null, 2)}\n`); }, }); @@ -36,10 +38,9 @@ const searchCmd = defineCommand({ async run({ args }) { await requireAuth(); const client = await getUserClient(); - const data = await runApi( - client.weather.search.get({ query: { q: args.q } }), - { action: 'search weather' }, - ); + const data = await runApi(client.weather.search.get({ query: { q: args.q } }), { + action: 'search weather', + }); process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); }, }); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b81e5c5e28..7fe621eb1f 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -7,10 +7,19 @@ "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, + "types": ["bun", "@cloudflare/workers-types/2022-10-31"], "baseUrl": ".", "paths": { "@packrat/analytics": ["../analytics/src"], - "@packrat/analytics/*": ["../analytics/src/*"] + "@packrat/analytics/*": ["../analytics/src/*"], + "@packrat/api-client": ["../api-client/src/index.ts"], + "@packrat/api-client/*": ["../api-client/src/*"], + "@packrat/api": ["../api/src/index.ts"], + "@packrat/api/*": ["../api/src/*"], + "@packrat/env": ["../env/src"], + "@packrat/env/*": ["../env/src/*"], + "@packrat/guards": ["../guards/src"], + "@packrat/guards/*": ["../guards/src/*"] } }, "include": ["src/**/*.ts", "scripts/**/*.ts"], diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index c1c00a7cd1..b937350f91 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -103,7 +103,7 @@ export async function call( try { const result = await promise; if (result.error || result.data == null) { - return formatError(result.status, result.error?.value, options); + return formatError({ status: result.status, body: result.error?.value, opts: options }); } return ok(result.data); } catch (e) { @@ -112,7 +112,8 @@ export async function call( } } -function formatError(status: number, body: unknown, opts: CallOptions): McpToolResult { +function formatError(args: { status: number; body: unknown; opts: CallOptions }): McpToolResult { + const { status, body, opts } = args; const action = opts.action ?? 'request'; const resource = opts.resourceHint ? ` (${opts.resourceHint})` : ''; const detail = extractErrorMessage(body); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index c54843e138..f56487eb5f 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -45,8 +45,8 @@ import { registerCatalogTools } from './tools/catalog'; import { registerFeedTools } from './tools/feed'; import { registerGuidesTools } from './tools/guides'; import { registerKnowledgeTools } from './tools/knowledge'; -import { registerPackTemplateTools } from './tools/packTemplates'; import { registerPackTools } from './tools/packs'; +import { registerPackTemplateTools } from './tools/packTemplates'; import { registerSeasonTools } from './tools/seasons'; import { registerTrailConditionTools } from './tools/trail-conditions'; import { registerTrailTools } from './tools/trails'; diff --git a/packages/mcp/src/resources.ts b/packages/mcp/src/resources.ts index 67ec5c8cfe..a0f8712822 100644 --- a/packages/mcp/src/resources.ts +++ b/packages/mcp/src/resources.ts @@ -18,24 +18,25 @@ function resourceError(opts: { uri: string; context: string; status: number; val return { uri, context, status, error: message }; } -function asContent(uri: string, body: object): { contents: Array<{ uri: string; mimeType: string; text: string }> } { +function asContent( + uri: string, + body: object, +): { contents: Array<{ uri: string; mimeType: string; text: string }> } { return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(body, null, 2) }], }; } -async function settle( - uri: string, - context: string, - promise: Promise, -): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> { +async function settle(args: { + uri: string; + context: string; + promise: Promise; +}): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> { + const { uri, context, promise } = args; try { const { data, error, status } = await promise; if (error || data == null) { - return asContent( - uri, - resourceError({ uri, context, status, value: error?.value ?? null }), - ); + return asContent(uri, resourceError({ uri, context, status, value: error?.value ?? null })); } return asContent(uri, data as object); } catch (e) { @@ -58,7 +59,11 @@ export function registerResources(agent: AgentContext): void { mimeType: 'application/json', }, (uri, { packId }) => - settle(uri.href, `pack:${String(packId)}`, agent.api.user.packs({ packId: String(packId) }).get()), + settle({ + uri: uri.href, + context: `pack:${String(packId)}`, + promise: agent.api.user.packs({ packId: String(packId) }).get(), + }), ); // ── Trip resource ───────────────────────────────────────────────────────── @@ -71,7 +76,11 @@ export function registerResources(agent: AgentContext): void { mimeType: 'application/json', }, (uri, { tripId }) => - settle(uri.href, `trip:${String(tripId)}`, agent.api.user.trips({ tripId: String(tripId) }).get()), + settle({ + uri: uri.href, + context: `trip:${String(tripId)}`, + promise: agent.api.user.trips({ tripId: String(tripId) }).get(), + }), ); // ── Catalog item resource ───────────────────────────────────────────────── @@ -84,11 +93,11 @@ export function registerResources(agent: AgentContext): void { mimeType: 'application/json', }, (uri, { itemId }) => - settle( - uri.href, - `catalog:${String(itemId)}`, - agent.api.user.catalog({ id: String(itemId) }).get(), - ), + settle({ + uri: uri.href, + context: `catalog:${String(itemId)}`, + promise: agent.api.user.catalog({ id: String(itemId) }).get(), + }), ); // ── Gear categories list (static URI) ───────────────────────────────────── @@ -100,6 +109,11 @@ export function registerResources(agent: AgentContext): void { 'Complete list of gear categories available in the PackRat catalog. Use this to discover what types of gear are available.', mimeType: 'application/json', }, - (uri) => settle(uri.href, 'gear_categories', agent.api.user.catalog.categories.get()), + (uri) => + settle({ + uri: uri.href, + context: 'gear_categories', + promise: agent.api.user.catalog.categories.get(), + }), ); } diff --git a/packages/mcp/src/tools/admin.ts b/packages/mcp/src/tools/admin.ts index 1af00a1afe..718a5f3aea 100644 --- a/packages/mcp/src/tools/admin.ts +++ b/packages/mcp/src/tools/admin.ts @@ -168,10 +168,10 @@ export function registerAdminTools(agent: AgentContext): void { }, }, async ({ q, sport, limit, offset }) => - call( - agent.api.admin.admin.trails.search.get({ query: { q, sport, limit, offset } }), - { action: 'admin search trails', ...ADMIN }, - ), + call(agent.api.admin.admin.trails.search.get({ query: { q, sport, limit, offset } }), { + action: 'admin search trails', + ...ADMIN, + }), ); agent.registerAdminTool( @@ -229,14 +229,11 @@ export function registerAdminTools(agent: AgentContext): void { inputSchema: { report_id: z.string() }, }, async ({ report_id }) => - call( - agent.api.admin.admin.trails.conditions({ reportId: report_id }).delete(), - { - action: 'admin delete trail report', - resourceHint: `report ${report_id}`, - ...ADMIN, - }, - ), + call(agent.api.admin.admin.trails.conditions({ reportId: report_id }).delete(), { + action: 'admin delete trail report', + resourceHint: `report ${report_id}`, + ...ADMIN, + }), ); // ── Analytics: platform ─────────────────────────────────────────────────── @@ -251,10 +248,10 @@ export function registerAdminTools(agent: AgentContext): void { }, }, async ({ period, range }) => - call( - agent.api.admin.admin.analytics.platform.growth.get({ query: { period, range } }), - { action: 'admin analytics growth', ...ADMIN }, - ), + call(agent.api.admin.admin.analytics.platform.growth.get({ query: { period, range } }), { + action: 'admin analytics growth', + ...ADMIN, + }), ); agent.registerAdminTool( @@ -267,10 +264,10 @@ export function registerAdminTools(agent: AgentContext): void { }, }, async ({ period, range }) => - call( - agent.api.admin.admin.analytics.platform.activity.get({ query: { period, range } }), - { action: 'admin analytics activity', ...ADMIN }, - ), + call(agent.api.admin.admin.analytics.platform.activity.get({ query: { period, range } }), { + action: 'admin analytics activity', + ...ADMIN, + }), ); agent.registerAdminTool( @@ -417,9 +414,10 @@ export function registerAdminTools(agent: AgentContext): void { inputSchema: { job_id: z.string() }, }, async ({ job_id }) => - call( - agent.api.admin.admin.analytics.catalog.etl({ jobId: job_id }).retry.post({}), - { action: 'admin ETL retry job', resourceHint: `job ${job_id}`, ...ADMIN }, - ), + call(agent.api.admin.admin.analytics.catalog.etl({ jobId: job_id }).retry.post({}), { + action: 'admin ETL retry job', + resourceHint: `job ${job_id}`, + ...ADMIN, + }), ); } diff --git a/packages/mcp/src/tools/ai.ts b/packages/mcp/src/tools/ai.ts index 4e2f6b65b9..f825f414b7 100644 --- a/packages/mcp/src/tools/ai.ts +++ b/packages/mcp/src/tools/ai.ts @@ -44,7 +44,6 @@ export function registerAiTools(agent: AgentContext): void { description: 'Get the PackRat DB schema — table names, columns, types.', inputSchema: {}, }, - async () => - call(agent.api.user.ai['db-schema'].get(), { action: 'fetch DB schema' }), + async () => call(agent.api.user.ai['db-schema'].get(), { action: 'fetch DB schema' }), ); } diff --git a/packages/mcp/src/tools/feed.ts b/packages/mcp/src/tools/feed.ts index 21acd7ebc1..cadba88bfc 100644 --- a/packages/mcp/src/tools/feed.ts +++ b/packages/mcp/src/tools/feed.ts @@ -118,13 +118,10 @@ export function registerFeedTools(agent: AgentContext): void { inputSchema: { post_id: z.string(), comment_id: z.string() }, }, async ({ post_id, comment_id }) => - call( - agent.api.user - .feed({ postId: post_id }) - .comments({ commentId: comment_id }) - .delete(), - { action: 'delete feed comment', resourceHint: `comment ${comment_id}` }, - ), + call(agent.api.user.feed({ postId: post_id }).comments({ commentId: comment_id }).delete(), { + action: 'delete feed comment', + resourceHint: `comment ${comment_id}`, + }), ); agent.server.registerTool( @@ -135,10 +132,7 @@ export function registerFeedTools(agent: AgentContext): void { }, async ({ post_id, comment_id }) => call( - agent.api.user - .feed({ postId: post_id }) - .comments({ commentId: comment_id }) - .like.post({}), + agent.api.user.feed({ postId: post_id }).comments({ commentId: comment_id }).like.post({}), { action: 'toggle feed comment like', resourceHint: `comment ${comment_id}` }, ), ); diff --git a/packages/mcp/src/tools/packTemplates.ts b/packages/mcp/src/tools/packTemplates.ts index 842111e641..08bc3c9f1e 100644 --- a/packages/mcp/src/tools/packTemplates.ts +++ b/packages/mcp/src/tools/packTemplates.ts @@ -12,8 +12,7 @@ export function registerPackTemplateTools(agent: AgentContext): void { description: 'List both user-owned and app-curated pack templates.', inputSchema: {}, }, - async () => - call(agent.api.user['pack-templates'].get(), { action: 'list pack templates' }), + async () => call(agent.api.user['pack-templates'].get(), { action: 'list pack templates' }), ); agent.server.registerTool( diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index 78e79751c6..c22bf25a3b 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -281,8 +281,7 @@ export function registerPackTools(agent: AgentContext): void { agent.server.registerTool( 'similar_pack_items', { - description: - 'Find catalog gear similar to a specific item in a pack (semantic similarity).', + description: 'Find catalog gear similar to a specific item in a pack (semantic similarity).', inputSchema: { pack_id: z.string(), item_id: z.string(), diff --git a/packages/mcp/src/tools/trail-conditions.ts b/packages/mcp/src/tools/trail-conditions.ts index 4e0a819f5f..1c056dc90e 100644 --- a/packages/mcp/src/tools/trail-conditions.ts +++ b/packages/mcp/src/tools/trail-conditions.ts @@ -145,10 +145,10 @@ export function registerTrailConditionTools(agent: AgentContext): void { } if (notes !== undefined) body.notes = notes; if (photos !== undefined) body.photos = photos; - return call( - agent.api.user['trail-conditions']({ reportId: report_id }).put(body), - { action: 'update trail report', resourceHint: `report ${report_id}` }, - ); + return call(agent.api.user['trail-conditions']({ reportId: report_id }).put(body), { + action: 'update trail report', + resourceHint: `report ${report_id}`, + }); }, ); diff --git a/packages/mcp/src/tools/trails.ts b/packages/mcp/src/tools/trails.ts index 34848ca71e..3831104006 100644 --- a/packages/mcp/src/tools/trails.ts +++ b/packages/mcp/src/tools/trails.ts @@ -21,9 +21,12 @@ export function registerTrailTools(agent: AgentContext): void { }, }, async ({ q, lat, lon, radius, sport, limit, offset }) => - call(agent.api.user.trails.search.get({ query: { q, lat, lon, radius, sport, limit, offset } }), { - action: 'search trails', - }), + call( + agent.api.user.trails.search.get({ query: { q, lat, lon, radius, sport, limit, offset } }), + { + action: 'search trails', + }, + ), ); // ── Get trail metadata ──────────────────────────────────────────────────── diff --git a/packages/mcp/src/tools/upload.ts b/packages/mcp/src/tools/upload.ts index 2331030db5..cefd094737 100644 --- a/packages/mcp/src/tools/upload.ts +++ b/packages/mcp/src/tools/upload.ts @@ -11,7 +11,11 @@ export function registerUploadTools(agent: AgentContext): void { inputSchema: { file_name: z.string().min(1), content_type: z.string().min(1), - size: z.number().int().min(1).max(10 * 1024 * 1024), + size: z + .number() + .int() + .min(1) + .max(10 * 1024 * 1024), }, }, async ({ file_name, content_type, size }) => diff --git a/packages/mcp/src/tools/user.ts b/packages/mcp/src/tools/user.ts index 046e6d3133..68ed99e29f 100644 --- a/packages/mcp/src/tools/user.ts +++ b/packages/mcp/src/tools/user.ts @@ -8,7 +8,7 @@ export function registerUserTools(agent: AgentContext): void { agent.server.registerTool( 'get_profile', { - description: 'Get the authenticated user\'s profile (firstName, lastName, email, avatar).', + description: "Get the authenticated user's profile (firstName, lastName, email, avatar).", inputSchema: {}, }, async () => call(agent.api.user.user.profile.get(), { action: 'get profile' }), @@ -17,7 +17,7 @@ export function registerUserTools(agent: AgentContext): void { agent.server.registerTool( 'update_profile', { - description: 'Update the authenticated user\'s profile fields.', + description: "Update the authenticated user's profile fields.", inputSchema: { first_name: z.string().min(1).optional(), last_name: z.string().min(1).optional(), From adfde8f94a46e099bc5b48ac32e7e274061bba2e Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 13:37:19 -0600 Subject: [PATCH 05/30] =?UTF-8?q?=F0=9F=9A=B8=20cli,mcp,env:=20satisfy=20p?= =?UTF-8?q?re-push=20lints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typeof checks → @packrat/guards (isObject, isString) - raw regex in packTemplates → explicit snake→camel map (no regex) - raw process.env in cli config → nodeEnv.PACKRAT_API_URL (new env entry) - 30 unsafe casts → centralised asRecord/asRecordArray/pickArray helpers in packages/cli/src/api/format.ts; the unavoidable cast is parked once, annotated safe-cast. - MCP feed comment input: parent_comment_id z.string() → z.number().int() so the input schema matches the API's body schema. After this, the pre-push gate (typeof, regex, env, circular, dup-deps, dup-guards, unauth-routes, sorted package.json, casts:strict) is green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/api/config.ts | 11 +++------ packages/cli/src/api/format.ts | 26 ++++++++++++++++++++ packages/cli/src/api/run.ts | 4 +-- packages/cli/src/commands/admin/catalog.ts | 7 +++--- packages/cli/src/commands/admin/packs.ts | 4 +-- packages/cli/src/commands/admin/stats.ts | 3 ++- packages/cli/src/commands/admin/trails.ts | 9 +++---- packages/cli/src/commands/admin/users.ts | 7 ++---- packages/cli/src/commands/auth/whoami.ts | 7 +++--- packages/cli/src/commands/catalog/index.ts | 18 ++++++-------- packages/cli/src/commands/packs/create.ts | 3 ++- packages/cli/src/commands/packs/get.ts | 3 ++- packages/cli/src/commands/packs/items.ts | 23 ++++++++--------- packages/cli/src/commands/packs/list.ts | 21 +++++++--------- packages/cli/src/commands/templates/index.ts | 17 ++++++------- packages/cli/src/commands/trails/index.ts | 6 ++--- packages/cli/src/commands/trips/index.ts | 21 +++++++--------- packages/cli/src/commands/user/index.ts | 3 ++- packages/cli/src/commands/weather/index.ts | 3 ++- packages/env/src/node.ts | 4 +++ packages/mcp/src/client.ts | 2 +- packages/mcp/src/resources.ts | 12 ++++----- packages/mcp/src/tools/catalog.ts | 1 + packages/mcp/src/tools/feed.ts | 2 +- packages/mcp/src/tools/packTemplates.ts | 6 +++-- packages/mcp/src/tools/weather.ts | 3 ++- 26 files changed, 120 insertions(+), 106 deletions(-) create mode 100644 packages/cli/src/api/format.ts diff --git a/packages/cli/src/api/config.ts b/packages/cli/src/api/config.ts index 141122dff6..40a0910e76 100644 --- a/packages/cli/src/api/config.ts +++ b/packages/cli/src/api/config.ts @@ -13,6 +13,8 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import { nodeEnv } from '@packrat/env/node'; +import { isObject } from '@packrat/guards'; import { z } from 'zod'; const DEFAULT_BASE_URL = 'https://packrat.world'; @@ -57,7 +59,7 @@ export async function loadConfig(): Promise { } // PACKRAT_API_URL env override always wins. Useful for local dev (e.g. // pointing the CLI at `http://localhost:8787`). - const envOverride = process.env.PACKRAT_API_URL?.trim(); + const envOverride = nodeEnv.PACKRAT_API_URL?.trim(); if (envOverride) cached.baseUrl = envOverride; return cached; } @@ -92,10 +94,5 @@ export async function clearSession(): Promise { export const CONFIG_FILE_PATH = CONFIG_PATH; function isNotFound(error: unknown): boolean { - return Boolean( - error && - typeof error === 'object' && - 'code' in error && - (error as { code: string }).code === 'ENOENT', - ); + return isObject(error) && 'code' in error && (error as { code: string }).code === 'ENOENT'; } diff --git a/packages/cli/src/api/format.ts b/packages/cli/src/api/format.ts new file mode 100644 index 0000000000..b1a25c73d3 --- /dev/null +++ b/packages/cli/src/api/format.ts @@ -0,0 +1,26 @@ +/** + * Tiny display helpers used by API commands. + * + * Treaty hands back precisely-typed `data` for each endpoint, but the CLI's + * tabular renderer accepts plain records. Rather than re-derive each row + * shape with a Zod parser, we narrow once and centralise the unavoidable + * type cast here so individual command files stay safe-cast-comment-free. + */ + +import { isObject } from '@packrat/guards'; + +/** Narrow an unknown value to a `Record` for keyed display. */ +export function asRecord(value: unknown): Record { + return isObject(value) ? (value as Record) : {}; // safe-cast: isObject() narrows to non-null object +} + +/** Narrow an unknown value to an array of records (empty when not an array). */ +export function asRecordArray(value: unknown): Record[] { + return Array.isArray(value) ? value.map(asRecord) : []; +} + +/** Pull `` out of an unknown value, returning a record array. */ +export function pickArray(value: unknown, key: string): Record[] { + const obj = asRecord(value); + return Array.isArray(obj[key]) ? (obj[key] as unknown[]).map(asRecord) : []; // safe-cast: Array.isArray narrows the LHS +} diff --git a/packages/cli/src/api/run.ts b/packages/cli/src/api/run.ts index 3721ad1b3d..7f6bdfb475 100644 --- a/packages/cli/src/api/run.ts +++ b/packages/cli/src/api/run.ts @@ -66,7 +66,7 @@ export async function tryApi( } function errorValue(error: unknown): unknown { - if (error && typeof error === 'object' && 'value' in error) { + if (isObject(error) && 'value' in error) { return (error as { value: unknown }).value; } return error; @@ -146,7 +146,7 @@ function extractMessage(body: unknown): string | null { if (body == null) return null; if (isString(body)) return body; if (isObject(body)) { - const obj = body as Record; + const obj = body as Record; // safe-cast: isObject() guard above narrows body if (isString(obj.message)) return obj.message; if (isString(obj.error)) return obj.error; try { diff --git a/packages/cli/src/commands/admin/catalog.ts b/packages/cli/src/commands/admin/catalog.ts index 2bafbd6d5e..668e88a093 100644 --- a/packages/cli/src/commands/admin/catalog.ts +++ b/packages/cli/src/commands/admin/catalog.ts @@ -1,6 +1,8 @@ +import { isString } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; +import { asRecordArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -29,11 +31,10 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const items = Array.isArray(data) ? (data as Record[]) : []; printTable( - items.map((it) => ({ + asRecordArray(data).map((it) => ({ id: it.id, - name: typeof it.name === 'string' ? it.name.slice(0, 60) : it.name, + name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, weight: it.weight, price: it.price, diff --git a/packages/cli/src/commands/admin/packs.ts b/packages/cli/src/commands/admin/packs.ts index c4eabb5ec5..93c4292d39 100644 --- a/packages/cli/src/commands/admin/packs.ts +++ b/packages/cli/src/commands/admin/packs.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; +import { asRecordArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -31,9 +32,8 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const items = Array.isArray(data) ? (data as Record[]) : []; printTable( - items.map((p) => ({ + asRecordArray(data).map((p) => ({ id: p.id, name: p.name, userId: p.userId, diff --git a/packages/cli/src/commands/admin/stats.ts b/packages/cli/src/commands/admin/stats.ts index 0f446d78fe..226ebf33de 100644 --- a/packages/cli/src/commands/admin/stats.ts +++ b/packages/cli/src/commands/admin/stats.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getAdminClient } from '../../api/client'; +import { asRecord } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -12,6 +13,6 @@ export default defineCommand({ action: 'admin stats', requiresAdmin: true, }); - printSummary(data as Record, 'Admin stats'); + printSummary(asRecord(data), 'Admin stats'); }, }); diff --git a/packages/cli/src/commands/admin/trails.ts b/packages/cli/src/commands/admin/trails.ts index c60cd3ecdc..6f2c6072a7 100644 --- a/packages/cli/src/commands/admin/trails.ts +++ b/packages/cli/src/commands/admin/trails.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; +import { asRecordArray, pickArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -26,11 +27,8 @@ const searchCmd = defineCommand({ }), { action: 'admin search trails', requiresAdmin: true }, ); - const trails = Array.isArray((data as Record).trails) - ? ((data as Record).trails as Record[]) - : []; printTable( - trails.map((t) => ({ osmId: t.osmId, name: t.name, sport: t.sport })), + pickArray(data, 'trails').map((t) => ({ osmId: t.osmId, name: t.name, sport: t.sport })), { title: 'Trails (admin)' }, ); }, @@ -63,9 +61,8 @@ const reportsCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const rows = Array.isArray(data) ? (data as Record[]) : []; printTable( - rows.map((r) => ({ + asRecordArray(data).map((r) => ({ id: r.id, trailName: r.trailName, condition: r.overallCondition, diff --git a/packages/cli/src/commands/admin/users.ts b/packages/cli/src/commands/admin/users.ts index 7b015264c0..1340380a6e 100644 --- a/packages/cli/src/commands/admin/users.ts +++ b/packages/cli/src/commands/admin/users.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; +import { asRecordArray, pickArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -29,11 +30,7 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const items = Array.isArray((data as Record).items) - ? ((data as Record).items as Record[]) - : Array.isArray(data) - ? (data as Record[]) - : []; + const items = Array.isArray(data) ? asRecordArray(data) : pickArray(data, 'items'); printTable( items.map((u) => ({ id: u.id, diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 2ffaed5035..e3c7a5150d 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; import { CONFIG_FILE_PATH, loadConfig } from '../../api/config'; +import { asRecord } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -10,15 +11,15 @@ export default defineCommand({ async run() { await requireAuth(); const client = await getUserClient(); - const profile = await runApi(client.user.profile.get(), { action: 'fetch profile' }); + const profile = asRecord(await runApi(client.user.profile.get(), { action: 'fetch profile' })); const config = await loadConfig(); printSummary( { baseUrl: config.baseUrl, userId: config.userId ?? '—', email: config.userEmail ?? '—', - firstName: (profile as Record).firstName ?? '—', - lastName: (profile as Record).lastName ?? '—', + firstName: profile.firstName ?? '—', + lastName: profile.lastName ?? '—', adminTokenSet: Boolean(config.adminToken), configFile: CONFIG_FILE_PATH, }, diff --git a/packages/cli/src/commands/catalog/index.ts b/packages/cli/src/commands/catalog/index.ts index 0eeb99cf58..d66b754f50 100644 --- a/packages/cli/src/commands/catalog/index.ts +++ b/packages/cli/src/commands/catalog/index.ts @@ -1,5 +1,7 @@ +import { isString } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { asRecord, pickArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary, printTable } from '../../shared'; @@ -27,13 +29,10 @@ const searchCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const items = Array.isArray((data as Record).items) - ? ((data as Record).items as Record[]) - : []; printTable( - items.map((it) => ({ + pickArray(data, 'items').map((it) => ({ id: it.id, - name: typeof it.name === 'string' ? it.name.slice(0, 60) : it.name, + name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, weight: it.weight, price: it.price, @@ -63,13 +62,10 @@ const semanticCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const items = Array.isArray((data as Record).items) - ? ((data as Record).items as Record[]) - : []; printTable( - items.map((it) => ({ + pickArray(data, 'items').map((it) => ({ id: it.id, - name: typeof it.name === 'string' ? it.name.slice(0, 60) : it.name, + name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, score: it.score, })), @@ -95,7 +91,7 @@ const getCmd = defineCommand({ process.stdout.write(`${JSON.stringify(item, null, 2)}\n`); return; } - const r = item as Record; + const r = asRecord(item); printSummary( { id: r.id, diff --git a/packages/cli/src/commands/packs/create.ts b/packages/cli/src/commands/packs/create.ts index b09282fe2f..5d0fae5afb 100644 --- a/packages/cli/src/commands/packs/create.ts +++ b/packages/cli/src/commands/packs/create.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; +import { asRecord } from '../../api/format'; import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; @@ -42,6 +43,6 @@ export default defineCommand({ }), { action: 'create pack' }, ); - consola.success(`Created pack ${(pack as Record).id ?? id}`); + consola.success(`Created pack ${asRecord(pack).id ?? id}`); }, }); diff --git a/packages/cli/src/commands/packs/get.ts b/packages/cli/src/commands/packs/get.ts index 2c5acc4cde..543ddebecb 100644 --- a/packages/cli/src/commands/packs/get.ts +++ b/packages/cli/src/commands/packs/get.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { asRecord } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -20,7 +21,7 @@ export default defineCommand({ process.stdout.write(`${JSON.stringify(pack, null, 2)}\n`); return; } - const p = pack as Record; + const p = asRecord(pack); printSummary( { id: p.id, diff --git a/packages/cli/src/commands/packs/items.ts b/packages/cli/src/commands/packs/items.ts index c40e99e3c5..0bb8ae7ea2 100644 --- a/packages/cli/src/commands/packs/items.ts +++ b/packages/cli/src/commands/packs/items.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { asRecordArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -20,20 +21,16 @@ export default defineCommand({ process.stdout.write(`${JSON.stringify(items, null, 2)}\n`); return; } - const rows = Array.isArray(items) ? items : []; printTable( - rows.map((it) => { - const r = it as Record; - return { - id: r.id, - name: r.name, - category: r.category, - weight: r.weight, - qty: r.quantity, - worn: r.worn, - consumable: r.consumable, - }; - }), + asRecordArray(items).map((r) => ({ + id: r.id, + name: r.name, + category: r.category, + weight: r.weight, + qty: r.quantity, + worn: r.worn, + consumable: r.consumable, + })), { title: `Items in ${args.id}` }, ); }, diff --git a/packages/cli/src/commands/packs/list.ts b/packages/cli/src/commands/packs/list.ts index b259e2866b..7f0ba4821f 100644 --- a/packages/cli/src/commands/packs/list.ts +++ b/packages/cli/src/commands/packs/list.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { asRecordArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -24,19 +25,15 @@ export default defineCommand({ process.stdout.write(`${JSON.stringify(packs, null, 2)}\n`); return; } - const rows = Array.isArray(packs) ? packs : []; printTable( - rows.map((p) => { - const r = p as Record; - return { - id: r.id, - name: r.name, - category: r.category, - items: r.itemCount, - totalGrams: r.totalWeight, - isPublic: r.isPublic, - }; - }), + asRecordArray(packs).map((r) => ({ + id: r.id, + name: r.name, + category: r.category, + items: r.itemCount, + totalGrams: r.totalWeight, + isPublic: r.isPublic, + })), { title: 'Your packs' }, ); }, diff --git a/packages/cli/src/commands/templates/index.ts b/packages/cli/src/commands/templates/index.ts index 47b797fd63..a9994616f9 100644 --- a/packages/cli/src/commands/templates/index.ts +++ b/packages/cli/src/commands/templates/index.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { asRecordArray } from '../../api/format'; import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -15,17 +16,13 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const rows = Array.isArray(data) ? data : []; printTable( - rows.map((t) => { - const r = t as Record; - return { - id: r.id, - name: r.name, - category: r.category, - isApp: r.isAppTemplate, - }; - }), + asRecordArray(data).map((r) => ({ + id: r.id, + name: r.name, + category: r.category, + isApp: r.isAppTemplate, + })), { title: 'Pack templates' }, ); }, diff --git a/packages/cli/src/commands/trails/index.ts b/packages/cli/src/commands/trails/index.ts index ad4cfc8d05..2e1ebd87ff 100644 --- a/packages/cli/src/commands/trails/index.ts +++ b/packages/cli/src/commands/trails/index.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { pickArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -36,11 +37,8 @@ const searchCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const trails = Array.isArray((data as Record).trails) - ? ((data as Record).trails as Record[]) - : []; printTable( - trails.map((t) => ({ + pickArray(data, 'trails').map((t) => ({ osmId: t.osmId, name: t.name, sport: t.sport, diff --git a/packages/cli/src/commands/trips/index.ts b/packages/cli/src/commands/trips/index.ts index f913c737ae..977167710e 100644 --- a/packages/cli/src/commands/trips/index.ts +++ b/packages/cli/src/commands/trips/index.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { asRecord, asRecordArray } from '../../api/format'; import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printSummary, printTable } from '../../shared'; @@ -15,18 +16,14 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(trips, null, 2)}\n`); return; } - const rows = Array.isArray(trips) ? trips : []; printTable( - rows.map((t) => { - const r = t as Record; - return { - id: r.id, - name: r.name, - startDate: r.startDate, - endDate: r.endDate, - packId: r.packId, - }; - }), + asRecordArray(trips).map((r) => ({ + id: r.id, + name: r.name, + startDate: r.startDate, + endDate: r.endDate, + packId: r.packId, + })), { title: 'Your trips' }, ); }, @@ -49,7 +46,7 @@ const getCmd = defineCommand({ process.stdout.write(`${JSON.stringify(trip, null, 2)}\n`); return; } - const t = trip as Record; + const t = asRecord(trip); printSummary( { id: t.id, diff --git a/packages/cli/src/commands/user/index.ts b/packages/cli/src/commands/user/index.ts index 368bbe7962..5380560384 100644 --- a/packages/cli/src/commands/user/index.ts +++ b/packages/cli/src/commands/user/index.ts @@ -1,5 +1,6 @@ import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; +import { asRecord } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -9,7 +10,7 @@ const getCmd = defineCommand({ await requireAuth(); const client = await getUserClient(); const data = await runApi(client.user.profile.get(), { action: 'get profile' }); - const r = data as Record; + const r = asRecord(data); printSummary( { firstName: r.firstName, diff --git a/packages/cli/src/commands/weather/index.ts b/packages/cli/src/commands/weather/index.ts index 6d854c8024..9f4eaa51c3 100644 --- a/packages/cli/src/commands/weather/index.ts +++ b/packages/cli/src/commands/weather/index.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; +import { asRecord } from '../../api/format'; import { requireAuth, runApi, tryApi } from '../../api/run'; const forecastCmd = defineCommand({ @@ -19,7 +20,7 @@ const forecastCmd = defineCommand({ consola.error(`Could not search for "${args.location}" (HTTP ${search.status})`); process.exit(1); } - const first = Array.isArray(search.data) ? (search.data[0] as Record) : null; + const first = Array.isArray(search.data) ? asRecord(search.data[0]) : null; const id = first && 'id' in first ? first.id : null; if (id == null) { consola.error(`No matching weather location for "${args.location}".`); diff --git a/packages/env/src/node.ts b/packages/env/src/node.ts index b7747c773c..83be509747 100644 --- a/packages/env/src/node.ts +++ b/packages/env/src/node.ts @@ -66,6 +66,9 @@ export const nodeEnvSchema = z.object({ // ── Test runner flags ───────────────────────────────────────────── VITEST: z.string().optional(), + // ── PackRat API (CLI base URL override) ─────────────────────────── + PACKRAT_API_URL: z.string().url().optional(), + // ── Debug / verbose ─────────────────────────────────────────────── DEBUG: z.string().optional(), @@ -104,6 +107,7 @@ export const nodeEnv = nodeEnvSchema.parse({ CLOUDFLARE_CONTAINER_ID: process.env.CLOUDFLARE_CONTAINER_ID, PORT: process.env.PORT, VITEST: process.env.VITEST, + PACKRAT_API_URL: process.env.PACKRAT_API_URL, DEBUG: process.env.DEBUG, E2E_TEST_EMAIL: process.env.E2E_TEST_EMAIL, E2E_TEST_PASSWORD: process.env.E2E_TEST_PASSWORD, diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index b937350f91..fc0cc1cd6a 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -162,7 +162,7 @@ function extractErrorMessage(body: unknown): string | null { if (body == null) return null; if (isString(body)) return body; if (isObject(body)) { - const obj = body as Record; + const obj = body as Record; // safe-cast: isObject() guard above narrows body if (isString(obj.message)) return obj.message; if (isString(obj.error)) return obj.error; try { diff --git a/packages/mcp/src/resources.ts b/packages/mcp/src/resources.ts index a0f8712822..02ec04b94f 100644 --- a/packages/mcp/src/resources.ts +++ b/packages/mcp/src/resources.ts @@ -1,4 +1,5 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { isObject, isString } from '@packrat/guards'; import type { AgentContext } from './types'; type TreatyResult = { @@ -9,12 +10,11 @@ type TreatyResult = { function resourceError(opts: { uri: string; context: string; status: number; value: unknown }) { const { uri, context, status, value } = opts; - const message = - typeof value === 'string' - ? value - : value && typeof value === 'object' && 'error' in value - ? String((value as { error: unknown }).error) - : `HTTP ${status}`; + const message = isString(value) + ? value + : isObject(value) && 'error' in value + ? String((value as { error: unknown }).error) + : `HTTP ${status}`; return { uri, context, status, error: message }; } diff --git a/packages/mcp/src/tools/catalog.ts b/packages/mcp/src/tools/catalog.ts index 1913a62a6f..db24033af3 100644 --- a/packages/mcp/src/tools/catalog.ts +++ b/packages/mcp/src/tools/catalog.ts @@ -194,6 +194,7 @@ export function registerCatalogTools(agent: AgentContext): void { ); } const comparison = responses.map((r) => { + // safe-cast: catalog item response is a JSON object; display only const it = (r.data ?? {}) as Record; return { id: it.id, diff --git a/packages/mcp/src/tools/feed.ts b/packages/mcp/src/tools/feed.ts index cadba88bfc..9293d65bc0 100644 --- a/packages/mcp/src/tools/feed.ts +++ b/packages/mcp/src/tools/feed.ts @@ -98,7 +98,7 @@ export function registerFeedTools(agent: AgentContext): void { inputSchema: { post_id: z.string(), content: z.string().min(1), - parent_comment_id: z.string().optional(), + parent_comment_id: z.number().int().optional(), }, }, async ({ post_id, content, parent_comment_id }) => diff --git a/packages/mcp/src/tools/packTemplates.ts b/packages/mcp/src/tools/packTemplates.ts index 08bc3c9f1e..a20eee085b 100644 --- a/packages/mcp/src/tools/packTemplates.ts +++ b/packages/mcp/src/tools/packTemplates.ts @@ -186,11 +186,13 @@ export function registerPackTemplateTools(agent: AgentContext): void { }, }, async ({ item_id, ...fields }) => { + // Explicit snake→camel rename avoids a raw regex; keys are stable + // because the input schema is fixed at registration time. + const SNAKE_TO_CAMEL: Record = { weight_unit: 'weightUnit' }; const body: Record = {}; for (const [k, v] of Object.entries(fields)) { if (v === undefined) continue; - const camel = k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); - body[camel] = v; + body[SNAKE_TO_CAMEL[k] ?? k] = v; } return call(agent.api.user['pack-templates'].items({ itemId: item_id }).patch(body), { action: 'update template item', diff --git a/packages/mcp/src/tools/weather.ts b/packages/mcp/src/tools/weather.ts index fe78d29773..a4c082ae35 100644 --- a/packages/mcp/src/tools/weather.ts +++ b/packages/mcp/src/tools/weather.ts @@ -1,3 +1,4 @@ +import { isObject } from '@packrat/guards'; import { z } from 'zod'; import { call, errMessage } from '../client'; import type { AgentContext } from '../types'; @@ -28,7 +29,7 @@ export function registerWeatherTools(agent: AgentContext): void { }); } const first = Array.isArray(search.data) ? search.data[0] : null; - const id = first && typeof first === 'object' && 'id' in first ? first.id : null; + const id = isObject(first) && 'id' in first ? first.id : null; if (id == null) return errMessage(`No weather location found for: ${location}`); return call(agent.api.user.weather.forecast.get({ query: { id: String(id) } }), { action: 'fetch weather forecast', From bd2e48b7549581f554a54c8b2be821d4bc553019 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 13:45:52 -0600 Subject: [PATCH 06/30] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20guards:=20consolidat?= =?UTF-8?q?e=20narrow=20helpers=20under=20to*=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the CLI-local format helpers (asRecord/asRecordArray/pickArray) into @packrat/guards and rename as part of the broader as*→to* convention so narrowing is one import path for the whole monorepo: to* (strict) : toString, toNumber, toBoolean, toDate — return T | undefined when the value matches to* (coercive) : toArray, toRecord, toRecordArray, toStringRecord — always return T with an empty default Legacy as* names are kept as @deprecated aliases so existing callers continue compiling. pickArray is gone — callers inline it as toRecordArray(toRecord(data).key), which reads better. CLI commands now import everything narrowing-related from @packrat/guards (no more local format.ts). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/api/format.ts | 26 ---- packages/cli/src/commands/admin/catalog.ts | 5 +- packages/cli/src/commands/admin/packs.ts | 4 +- packages/cli/src/commands/admin/stats.ts | 4 +- packages/cli/src/commands/admin/trails.ts | 10 +- packages/cli/src/commands/admin/users.ts | 4 +- packages/cli/src/commands/auth/whoami.ts | 4 +- packages/cli/src/commands/catalog/index.ts | 9 +- packages/cli/src/commands/packs/create.ts | 4 +- packages/cli/src/commands/packs/get.ts | 4 +- packages/cli/src/commands/packs/items.ts | 4 +- packages/cli/src/commands/packs/list.ts | 4 +- packages/cli/src/commands/templates/index.ts | 4 +- packages/cli/src/commands/trails/index.ts | 4 +- packages/cli/src/commands/trips/index.ts | 6 +- packages/cli/src/commands/user/index.ts | 4 +- packages/cli/src/commands/weather/index.ts | 4 +- packages/guards/src/narrow.ts | 134 +++++++++++++------ 18 files changed, 133 insertions(+), 105 deletions(-) delete mode 100644 packages/cli/src/api/format.ts diff --git a/packages/cli/src/api/format.ts b/packages/cli/src/api/format.ts deleted file mode 100644 index b1a25c73d3..0000000000 --- a/packages/cli/src/api/format.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Tiny display helpers used by API commands. - * - * Treaty hands back precisely-typed `data` for each endpoint, but the CLI's - * tabular renderer accepts plain records. Rather than re-derive each row - * shape with a Zod parser, we narrow once and centralise the unavoidable - * type cast here so individual command files stay safe-cast-comment-free. - */ - -import { isObject } from '@packrat/guards'; - -/** Narrow an unknown value to a `Record` for keyed display. */ -export function asRecord(value: unknown): Record { - return isObject(value) ? (value as Record) : {}; // safe-cast: isObject() narrows to non-null object -} - -/** Narrow an unknown value to an array of records (empty when not an array). */ -export function asRecordArray(value: unknown): Record[] { - return Array.isArray(value) ? value.map(asRecord) : []; -} - -/** Pull `` out of an unknown value, returning a record array. */ -export function pickArray(value: unknown, key: string): Record[] { - const obj = asRecord(value); - return Array.isArray(obj[key]) ? (obj[key] as unknown[]).map(asRecord) : []; // safe-cast: Array.isArray narrows the LHS -} diff --git a/packages/cli/src/commands/admin/catalog.ts b/packages/cli/src/commands/admin/catalog.ts index 668e88a093..ae743b8a53 100644 --- a/packages/cli/src/commands/admin/catalog.ts +++ b/packages/cli/src/commands/admin/catalog.ts @@ -1,8 +1,7 @@ -import { isString } from '@packrat/guards'; +import { isString, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; -import { asRecordArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -32,7 +31,7 @@ const listCmd = defineCommand({ return; } printTable( - asRecordArray(data).map((it) => ({ + toRecordArray(data).map((it) => ({ id: it.id, name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, diff --git a/packages/cli/src/commands/admin/packs.ts b/packages/cli/src/commands/admin/packs.ts index 93c4292d39..0dc464483a 100644 --- a/packages/cli/src/commands/admin/packs.ts +++ b/packages/cli/src/commands/admin/packs.ts @@ -1,7 +1,7 @@ +import { toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; -import { asRecordArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -33,7 +33,7 @@ const listCmd = defineCommand({ return; } printTable( - asRecordArray(data).map((p) => ({ + toRecordArray(data).map((p) => ({ id: p.id, name: p.name, userId: p.userId, diff --git a/packages/cli/src/commands/admin/stats.ts b/packages/cli/src/commands/admin/stats.ts index 226ebf33de..46b9ce5663 100644 --- a/packages/cli/src/commands/admin/stats.ts +++ b/packages/cli/src/commands/admin/stats.ts @@ -1,6 +1,6 @@ +import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getAdminClient } from '../../api/client'; -import { asRecord } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -13,6 +13,6 @@ export default defineCommand({ action: 'admin stats', requiresAdmin: true, }); - printSummary(asRecord(data), 'Admin stats'); + printSummary(toRecord(data), 'Admin stats'); }, }); diff --git a/packages/cli/src/commands/admin/trails.ts b/packages/cli/src/commands/admin/trails.ts index 6f2c6072a7..19834cbf65 100644 --- a/packages/cli/src/commands/admin/trails.ts +++ b/packages/cli/src/commands/admin/trails.ts @@ -1,7 +1,7 @@ +import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; -import { asRecordArray, pickArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -28,7 +28,11 @@ const searchCmd = defineCommand({ { action: 'admin search trails', requiresAdmin: true }, ); printTable( - pickArray(data, 'trails').map((t) => ({ osmId: t.osmId, name: t.name, sport: t.sport })), + toRecordArray(toRecord(data).trails).map((t) => ({ + osmId: t.osmId, + name: t.name, + sport: t.sport, + })), { title: 'Trails (admin)' }, ); }, @@ -62,7 +66,7 @@ const reportsCmd = defineCommand({ return; } printTable( - asRecordArray(data).map((r) => ({ + toRecordArray(data).map((r) => ({ id: r.id, trailName: r.trailName, condition: r.overallCondition, diff --git a/packages/cli/src/commands/admin/users.ts b/packages/cli/src/commands/admin/users.ts index 1340380a6e..1d3eeb9cf6 100644 --- a/packages/cli/src/commands/admin/users.ts +++ b/packages/cli/src/commands/admin/users.ts @@ -1,7 +1,7 @@ +import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; -import { asRecordArray, pickArray } from '../../api/format'; import { requireAdmin, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -30,7 +30,7 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const items = Array.isArray(data) ? asRecordArray(data) : pickArray(data, 'items'); + const items = Array.isArray(data) ? toRecordArray(data) : toRecordArray(toRecord(data).items); printTable( items.map((u) => ({ id: u.id, diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index e3c7a5150d..32abc76e12 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -1,8 +1,8 @@ +import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; import { CONFIG_FILE_PATH, loadConfig } from '../../api/config'; -import { asRecord } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -11,7 +11,7 @@ export default defineCommand({ async run() { await requireAuth(); const client = await getUserClient(); - const profile = asRecord(await runApi(client.user.profile.get(), { action: 'fetch profile' })); + const profile = toRecord(await runApi(client.user.profile.get(), { action: 'fetch profile' })); const config = await loadConfig(); printSummary( { diff --git a/packages/cli/src/commands/catalog/index.ts b/packages/cli/src/commands/catalog/index.ts index d66b754f50..c9aa8085a9 100644 --- a/packages/cli/src/commands/catalog/index.ts +++ b/packages/cli/src/commands/catalog/index.ts @@ -1,7 +1,6 @@ -import { isString } from '@packrat/guards'; +import { isString, toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { asRecord, pickArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary, printTable } from '../../shared'; @@ -30,7 +29,7 @@ const searchCmd = defineCommand({ return; } printTable( - pickArray(data, 'items').map((it) => ({ + toRecordArray(toRecord(data).items).map((it) => ({ id: it.id, name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, @@ -63,7 +62,7 @@ const semanticCmd = defineCommand({ return; } printTable( - pickArray(data, 'items').map((it) => ({ + toRecordArray(toRecord(data).items).map((it) => ({ id: it.id, name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, @@ -91,7 +90,7 @@ const getCmd = defineCommand({ process.stdout.write(`${JSON.stringify(item, null, 2)}\n`); return; } - const r = asRecord(item); + const r = toRecord(item); printSummary( { id: r.id, diff --git a/packages/cli/src/commands/packs/create.ts b/packages/cli/src/commands/packs/create.ts index 5d0fae5afb..d51119a173 100644 --- a/packages/cli/src/commands/packs/create.ts +++ b/packages/cli/src/commands/packs/create.ts @@ -1,7 +1,7 @@ +import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; -import { asRecord } from '../../api/format'; import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; @@ -43,6 +43,6 @@ export default defineCommand({ }), { action: 'create pack' }, ); - consola.success(`Created pack ${asRecord(pack).id ?? id}`); + consola.success(`Created pack ${toRecord(pack).id ?? id}`); }, }); diff --git a/packages/cli/src/commands/packs/get.ts b/packages/cli/src/commands/packs/get.ts index 543ddebecb..deb11f152e 100644 --- a/packages/cli/src/commands/packs/get.ts +++ b/packages/cli/src/commands/packs/get.ts @@ -1,6 +1,6 @@ +import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { asRecord } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -21,7 +21,7 @@ export default defineCommand({ process.stdout.write(`${JSON.stringify(pack, null, 2)}\n`); return; } - const p = asRecord(pack); + const p = toRecord(pack); printSummary( { id: p.id, diff --git a/packages/cli/src/commands/packs/items.ts b/packages/cli/src/commands/packs/items.ts index 0bb8ae7ea2..86f2aa6867 100644 --- a/packages/cli/src/commands/packs/items.ts +++ b/packages/cli/src/commands/packs/items.ts @@ -1,6 +1,6 @@ +import { toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { asRecordArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -22,7 +22,7 @@ export default defineCommand({ return; } printTable( - asRecordArray(items).map((r) => ({ + toRecordArray(items).map((r) => ({ id: r.id, name: r.name, category: r.category, diff --git a/packages/cli/src/commands/packs/list.ts b/packages/cli/src/commands/packs/list.ts index 7f0ba4821f..db63e4a711 100644 --- a/packages/cli/src/commands/packs/list.ts +++ b/packages/cli/src/commands/packs/list.ts @@ -1,6 +1,6 @@ +import { toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { asRecordArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -26,7 +26,7 @@ export default defineCommand({ return; } printTable( - asRecordArray(packs).map((r) => ({ + toRecordArray(packs).map((r) => ({ id: r.id, name: r.name, category: r.category, diff --git a/packages/cli/src/commands/templates/index.ts b/packages/cli/src/commands/templates/index.ts index a9994616f9..6ea3450daf 100644 --- a/packages/cli/src/commands/templates/index.ts +++ b/packages/cli/src/commands/templates/index.ts @@ -1,6 +1,6 @@ +import { toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { asRecordArray } from '../../api/format'; import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -17,7 +17,7 @@ const listCmd = defineCommand({ return; } printTable( - asRecordArray(data).map((r) => ({ + toRecordArray(data).map((r) => ({ id: r.id, name: r.name, category: r.category, diff --git a/packages/cli/src/commands/trails/index.ts b/packages/cli/src/commands/trails/index.ts index 2e1ebd87ff..eee3faed08 100644 --- a/packages/cli/src/commands/trails/index.ts +++ b/packages/cli/src/commands/trails/index.ts @@ -1,6 +1,6 @@ +import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { pickArray } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -38,7 +38,7 @@ const searchCmd = defineCommand({ return; } printTable( - pickArray(data, 'trails').map((t) => ({ + toRecordArray(toRecord(data).trails).map((t) => ({ osmId: t.osmId, name: t.name, sport: t.sport, diff --git a/packages/cli/src/commands/trips/index.ts b/packages/cli/src/commands/trips/index.ts index 977167710e..db75993812 100644 --- a/packages/cli/src/commands/trips/index.ts +++ b/packages/cli/src/commands/trips/index.ts @@ -1,6 +1,6 @@ +import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { asRecord, asRecordArray } from '../../api/format'; import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printSummary, printTable } from '../../shared'; @@ -17,7 +17,7 @@ const listCmd = defineCommand({ return; } printTable( - asRecordArray(trips).map((r) => ({ + toRecordArray(trips).map((r) => ({ id: r.id, name: r.name, startDate: r.startDate, @@ -46,7 +46,7 @@ const getCmd = defineCommand({ process.stdout.write(`${JSON.stringify(trip, null, 2)}\n`); return; } - const t = asRecord(trip); + const t = toRecord(trip); printSummary( { id: t.id, diff --git a/packages/cli/src/commands/user/index.ts b/packages/cli/src/commands/user/index.ts index 5380560384..b52c16f555 100644 --- a/packages/cli/src/commands/user/index.ts +++ b/packages/cli/src/commands/user/index.ts @@ -1,6 +1,6 @@ +import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { asRecord } from '../../api/format'; import { requireAuth, runApi } from '../../api/run'; import { printSummary } from '../../shared'; @@ -10,7 +10,7 @@ const getCmd = defineCommand({ await requireAuth(); const client = await getUserClient(); const data = await runApi(client.user.profile.get(), { action: 'get profile' }); - const r = asRecord(data); + const r = toRecord(data); printSummary( { firstName: r.firstName, diff --git a/packages/cli/src/commands/weather/index.ts b/packages/cli/src/commands/weather/index.ts index 9f4eaa51c3..3aca3bf072 100644 --- a/packages/cli/src/commands/weather/index.ts +++ b/packages/cli/src/commands/weather/index.ts @@ -1,7 +1,7 @@ +import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; -import { asRecord } from '../../api/format'; import { requireAuth, runApi, tryApi } from '../../api/run'; const forecastCmd = defineCommand({ @@ -20,7 +20,7 @@ const forecastCmd = defineCommand({ consola.error(`Could not search for "${args.location}" (HTTP ${search.status})`); process.exit(1); } - const first = Array.isArray(search.data) ? asRecord(search.data[0]) : null; + const first = Array.isArray(search.data) ? toRecord(search.data[0]) : null; const id = first && 'id' in first ? first.id : null; if (id == null) { consola.error(`No matching weather location for "${args.location}".`); diff --git a/packages/guards/src/narrow.ts b/packages/guards/src/narrow.ts index 3518e00cdb..3c89746f57 100644 --- a/packages/guards/src/narrow.ts +++ b/packages/guards/src/narrow.ts @@ -1,48 +1,40 @@ /** - * Narrowing helpers that return `T | undefined` instead of throwing, - * and coercion helpers that massage values into well-typed shapes. + * Narrowing helpers for system boundaries. * - * Use these at system boundaries (API responses, CSV rows, unknown records) - * instead of `as` casts. + * Two flavours, both named `to*`: + * + * - **Strict narrow**: returns `T | undefined` (`toString`, `toNumber`, + * `toBoolean`, `toDate`). The caller decides what to do when the value + * doesn't match the type. + * - **Coercive narrow**: returns `T` with a safe default (`toArray`, + * `toRecord`, `toRecordArray`, `toStringRecord`). Use when the call site + * wants to keep working with empty data rather than branch. + * + * The legacy `asString` / `asNumber` / `asBoolean` / `asDate` / + * `asStringRecord` / `asArray` names are kept as aliases for back-compat so + * existing call sites compile unchanged. */ +// ── Strict narrow (T | undefined) ───────────────────────────────────────── + /** Returns the value if it's a string, otherwise undefined. */ -export const asString = (value: unknown): string | undefined => +// biome-ignore lint/suspicious/noShadowRestrictedNames: intentional — paired with toNumber/toBoolean/toDate as the package's narrow-or-undefined API +export const toString = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; /** Returns the value if it's a finite number, otherwise undefined. */ -export const asNumber = (value: unknown): number | undefined => +export const toNumber = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined; /** Returns the value if it's a boolean, otherwise undefined. */ -export const asBoolean = (value: unknown): boolean | undefined => +export const toBoolean = (value: unknown): boolean | undefined => typeof value === 'boolean' ? value : undefined; -/** - * Coerces null → undefined for use with `exactOptionalPropertyTypes` - * stores that only accept `string | undefined`, not `string | null`. - */ -export const nullToUndefined = (value: T | null): T | undefined => - value === null ? undefined : value; - -/** - * Type-safe indexOf — searches an array for an unknown value and returns its - * index, or -1 if the value is not a member of the array. - * - * Avoids `as ElementType` casts when the call site only has a `string` (or - * other broad type) but the array is typed as a specific union or tuple. - * - * @example - * safeIndexOf(['g', 'oz', 'kg', 'lb'], field.state.value) // 0-3 or -1 - */ -export const safeIndexOf = (array: readonly T[], value: unknown): number => - (array as readonly unknown[]).indexOf(value); - /** * Returns the value if it's a Date, parses it if it's a string/number, * otherwise undefined. */ -export const asDate = (value: unknown): Date | undefined => { +export const toDate = (value: unknown): Date | undefined => { if (value instanceof Date) return value; if (typeof value === 'string' || typeof value === 'number') { const parsed = new Date(value); @@ -51,11 +43,54 @@ export const asDate = (value: unknown): Date | undefined => { return undefined; }; +// ── Coercive narrow (always returns T, with a safe default) ─────────────── + +/** + * Wraps a single value in an array if it isn't one already. + * Useful for normalising API fields that can be `T | T[]`. + * + * @example + * toArray('foo') // ['foo'] + * toArray(['foo']) // ['foo'] + * toArray(undefined) // [] + */ +export const toArray = (value: T | T[] | null | undefined): T[] => { + if (value === null || value === undefined) return []; + return Array.isArray(value) ? value : [value]; +}; + +/** + * Narrow an unknown value to `Record` for keyed display, or + * return `{}` if it isn't a plain object. Use this at API/JSON boundaries + * where you only need to read string-keyed fields rather than re-validate + * the full shape with a Zod parser. + * + * @example + * toRecord(unknown) // { ... } or {} + * toRecord({ a: 1 }).a // 1 + */ +export const toRecord = (value: unknown): Record => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; + // safe-cast: guards package internal narrowing — value confirmed non-null, non-array object above + return value as Record; +}; + +/** + * Narrow an unknown value to `Record[]` — useful for + * tabular rendering of API list responses where Treaty's exact element type + * isn't worth threading through every printTable call site. + * + * @example + * toRecordArray(apiResponse).map(r => ({ id: r.id, name: r.name })) + */ +export const toRecordArray = (value: unknown): Record[] => + Array.isArray(value) ? value.map(toRecord) : []; + /** * Returns a `Record` from an unknown value, keeping only * string-valued entries. Returns `{}` if the input isn't a plain object. */ -export const asStringRecord = (value: unknown): Record => { +export const toStringRecord = (value: unknown): Record => { if (value === null || typeof value !== 'object') return {}; const out: Record = {}; // safe-cast: guards package internal narrowing — value is confirmed non-null object by preceding check @@ -65,22 +100,39 @@ export const asStringRecord = (value: unknown): Record => { return out; }; +// ── Back-compat aliases (use the to* name in new code) ───────────────────── + +/** @deprecated Use `toString` instead. */ +export const asString = toString; +/** @deprecated Use `toNumber` instead. */ +export const asNumber = toNumber; +/** @deprecated Use `toBoolean` instead. */ +export const asBoolean = toBoolean; +/** @deprecated Use `toDate` instead. */ +export const asDate = toDate; +/** @deprecated Use `toStringRecord` instead. */ +export const asStringRecord = toStringRecord; +/** @deprecated Use `toArray` instead. */ +export const asArray = toArray; + +// ── Other utilities ─────────────────────────────────────────────────────── + /** - * Wraps a single value in an array if it isn't one already. - * Useful for normalising API fields that can be `T | T[]`. + * Coerces null → undefined for use with `exactOptionalPropertyTypes` + * stores that only accept `string | undefined`, not `string | null`. + */ +export const nullToUndefined = (value: T | null): T | undefined => + value === null ? undefined : value; + +/** + * Type-safe indexOf — searches an array for an unknown value and returns its + * index, or -1 if the value is not a member of the array. * * @example - * toArray('foo') // ['foo'] - * toArray(['foo']) // ['foo'] - * toArray(undefined) // [] + * safeIndexOf(['g', 'oz', 'kg', 'lb'], field.state.value) // 0-3 or -1 */ -export const toArray = (value: T | T[] | null | undefined): T[] => { - if (value === null || value === undefined) return []; - return Array.isArray(value) ? value : [value]; -}; - -/** Alias for toArray — prefer whichever reads more clearly at the call site. */ -export const asArray = toArray; +export const safeIndexOf = (array: readonly T[], value: unknown): number => + (array as readonly unknown[]).indexOf(value); // safe-cast: search is read-only; result is a numeric index, no narrowing on T /** * Filters nullish values out of an array and narrows the element type. From 2d70fab891dcdc25f6767d6dfd78e3c340135c09 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 13:48:22 -0600 Subject: [PATCH 07/30] =?UTF-8?q?=F0=9F=A9=B9=20cli:=20load=20config=20whe?= =?UTF-8?q?n=20~/.packrat/config.json=20is=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isNotFound was using radash's isObject() which returns true only for plain `{}` objects — Node fs errors are Error instances and slipped past the check, so the ENOENT was rethrown instead of falling back to an empty config. Switch to `error instanceof Error` + ErrnoException code check. Smoke-tested: packrat packs list → "Not signed in. Run packrat auth login..." packrat admin stats → "No admin token. Run packrat admin login..." Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/api/config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/api/config.ts b/packages/cli/src/api/config.ts index 40a0910e76..35448a0179 100644 --- a/packages/cli/src/api/config.ts +++ b/packages/cli/src/api/config.ts @@ -14,7 +14,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { nodeEnv } from '@packrat/env/node'; -import { isObject } from '@packrat/guards'; import { z } from 'zod'; const DEFAULT_BASE_URL = 'https://packrat.world'; @@ -94,5 +93,9 @@ export async function clearSession(): Promise { export const CONFIG_FILE_PATH = CONFIG_PATH; function isNotFound(error: unknown): boolean { - return isObject(error) && 'code' in error && (error as { code: string }).code === 'ENOENT'; + // Node fs errors are Error instances (not plain objects), so isObject() + // from radash returns false. Check by instance. + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ); } From 1cb8d84da2371e9682bde52a34affe3f763ad0a7 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 15:34:40 -0600 Subject: [PATCH 08/30] =?UTF-8?q?=F0=9F=9B=82=20api:=20relax=20query=20sch?= =?UTF-8?q?emas=20so=20Treaty=20sees=20them=20as=20truly=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the redundant `.optional().default(N)` pattern from every query schema where the handler already applies its own default. Treaty types default-with-optional as required-with-default, which forced every typed client to pass `period: 'month', range: 12, sort: undefined, limit, offset` even on calls that should be one-liners. Affected: catalog: page, limit, vector-search limit/offset, categories limit guides: list/search page, limit ai: rag-search limit admin: analytics platform period/range, analytics catalog limit (x4), analytics ETL job-failures limit, trails search/conditions limit/offset, packs-list/trail-conditions limit feed: posts/comments page, limit packs: includePublic trail-conditions: list limit Body-schema defaults left alone — they're a different cost/benefit and don't churn every caller the way query params do. Same commit: clean up `includeDeleted` and gap-analysis `duration`: - admin/packs-list `includeDeleted` was declared in schema but unread by the handler. Wire it up (admins can now toggle soft-deleted packs) and switch to z.coerce.boolean(). - admin/users-list `includeDeleted` was dead code (Better Auth doesn't support user soft-delete). Drop from the schema entirely. - admin/trails conditions `includeDeleted` keeps its behavior but switches to z.coerce.boolean() so typed clients send `true`/`false` instead of the string literal `'true'`. - GapAnalysisRequestSchema.duration: z.string() → z.coerce.number().int() so callers can send the natural number form. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/admin/analytics/catalog.ts | 8 ++++---- .../api/src/routes/admin/analytics/platform.ts | 5 +++-- packages/api/src/routes/admin/index.ts | 8 +++++--- packages/api/src/routes/admin/trails.ts | 8 ++++---- packages/api/src/routes/catalog/index.ts | 5 +++-- packages/api/src/routes/feed/index.ts | 14 ++++++++------ packages/api/src/routes/guides/index.ts | 4 ++-- packages/api/src/routes/packs/index.ts | 3 ++- packages/api/src/routes/trailConditions/reports.ts | 3 ++- packages/api/src/schemas/ai.ts | 3 ++- packages/api/src/schemas/catalog.ts | 11 +++++++---- packages/api/src/schemas/guides.ts | 10 ++++++---- packages/api/src/schemas/packs.ts | 3 ++- 13 files changed, 50 insertions(+), 35 deletions(-) diff --git a/packages/api/src/routes/admin/analytics/catalog.ts b/packages/api/src/routes/admin/analytics/catalog.ts index 88b9151970..6008870688 100644 --- a/packages/api/src/routes/admin/analytics/catalog.ts +++ b/packages/api/src/routes/admin/analytics/catalog.ts @@ -131,7 +131,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) }, { query: z.object({ - limit: z.coerce.number().int().min(1).max(100).optional().default(25), + limit: z.coerce.number().int().min(1).max(100).optional(), }), response: { 200: t.Array(BrandRowSchema), ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Top gear brands' }, @@ -227,7 +227,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) }, { query: z.object({ - limit: z.coerce.number().int().min(1).max(200).optional().default(50), + limit: z.coerce.number().int().min(1).max(200).optional(), }), response: { 200: EtlResponseSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'ETL pipeline history' }, @@ -309,7 +309,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) }, { query: z.object({ - limit: z.coerce.number().int().min(1).max(100).optional().default(20), + limit: z.coerce.number().int().min(1).max(100).optional(), }), response: { 200: EtlFailureSummarySchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Top ETL validation failure patterns' }, @@ -372,7 +372,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) { params: z.object({ jobId: z.string().uuid() }), query: z.object({ - limit: z.coerce.number().int().min(1).max(200).optional().default(50), + limit: z.coerce.number().int().min(1).max(200).optional(), }), response: { 200: EtlJobFailuresSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'Validation failures for a specific ETL job' }, diff --git a/packages/api/src/routes/admin/analytics/platform.ts b/packages/api/src/routes/admin/analytics/platform.ts index 4b3f35bae1..05fd00c6dd 100644 --- a/packages/api/src/routes/admin/analytics/platform.ts +++ b/packages/api/src/routes/admin/analytics/platform.ts @@ -18,9 +18,10 @@ import { and, count, desc, eq, gte, sql } from 'drizzle-orm'; import { Elysia, status, t } from 'elysia'; import { z } from 'zod'; +// Defaults applied in handlers so Treaty types these as truly optional. const PeriodSchema = z.object({ - period: z.enum(['day', 'week', 'month']).optional().default('month'), - range: z.coerce.number().int().min(1).max(365).optional().default(12), + period: z.enum(['day', 'week', 'month']).optional(), + range: z.coerce.number().int().min(1).max(365).optional(), }); function getStartDate(period: 'day' | 'week' | 'month', range: number): Date { diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 0ab63aeac9..e00d7d2b1b 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -275,7 +275,6 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) limit: z.coerce.number().int().positive().max(100).optional(), offset: z.coerce.number().int().min(0).optional(), q: z.string().optional(), - includeDeleted: z.string().optional(), }), response: { 200: AdminUsersListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List users' }, @@ -291,6 +290,7 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) const limit = Number(query.limit ?? 100); const offset = Number(query.offset ?? 0); const search = query.q; + const includeDeleted = query.includeDeleted ?? false; const searchFilter = search ? or( ilike(packs.name, `%${search}%`), @@ -299,7 +299,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) ilike(users.email, `%${search}%`), ) : undefined; - const whereClause = and(eq(packs.deleted, false), searchFilter); + const whereClause = includeDeleted + ? searchFilter + : and(eq(packs.deleted, false), searchFilter); const [packsList, [totalRow]] = await Promise.all([ db @@ -349,7 +351,7 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) limit: z.coerce.number().int().positive().max(100).optional(), offset: z.coerce.number().int().min(0).optional(), q: z.string().optional(), - includeDeleted: z.string().optional(), + includeDeleted: z.coerce.boolean().optional(), }), response: { 200: AdminPacksListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List packs' }, diff --git a/packages/api/src/routes/admin/trails.ts b/packages/api/src/routes/admin/trails.ts index c04c388f75..3c777455d0 100644 --- a/packages/api/src/routes/admin/trails.ts +++ b/packages/api/src/routes/admin/trails.ts @@ -253,7 +253,7 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) const limit = query.limit ?? 50; const offset = query.offset ?? 0; const search = query.q; - const includeDeleted = query.includeDeleted === 'true'; + const includeDeleted = query.includeDeleted ?? false; try { const deletedFilter = includeDeleted ? undefined : eq(trailConditionReports.deleted, false); @@ -310,9 +310,9 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) { query: z.object({ q: z.string().optional(), - limit: z.coerce.number().int().min(1).max(100).optional().default(50), - offset: z.coerce.number().int().min(0).optional().default(0), - includeDeleted: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), + includeDeleted: z.coerce.boolean().optional(), }), response: { 200: TrailConditionsListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List all trail condition reports' }, diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index 8e6f31b814..21ffb876fe 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -45,7 +45,7 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) .get( '/', async ({ query }) => { - const { page, limit, q, category: encodedCategory, sort } = query; + const { page = 1, limit = 20, q, category: encodedCategory, sort } = query; let category: string | undefined; if (isString(encodedCategory) && encodedCategory.length > 0) { try { @@ -119,8 +119,9 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) return categories; }, { + // Service applies its own default (10); keep schema truly optional. query: z.object({ - limit: z.coerce.number().int().positive().optional().default(10), + limit: z.coerce.number().int().positive().optional(), }), isAuthenticated: true, detail: { diff --git a/packages/api/src/routes/feed/index.ts b/packages/api/src/routes/feed/index.ts index bece1afa17..ce2252a510 100644 --- a/packages/api/src/routes/feed/index.ts +++ b/packages/api/src/routes/feed/index.ts @@ -18,7 +18,7 @@ export const feedRoutes = new Elysia({ prefix: '/feed' }) .get( '/', async ({ query, user }) => { - const { page, limit } = query; + const { page = 1, limit = 20 } = query; const db = createDb(); const offset = (page - 1) * limit; @@ -88,8 +88,9 @@ export const feedRoutes = new Elysia({ prefix: '/feed' }) }, { query: z.object({ - page: z.coerce.number().int().min(1).optional().default(1), - limit: z.coerce.number().int().min(1).max(50).optional().default(20), + // Defaults applied in handler so Treaty types these as truly optional. + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(50).optional(), }), isAuthenticated: true, detail: { tags: ['Feed'], summary: 'List social feed posts', security: [{ bearerAuth: [] }] }, @@ -242,7 +243,7 @@ export const feedRoutes = new Elysia({ prefix: '/feed' }) '/:postId/comments', async ({ params, query, user }) => { const postId = Number(params.postId); - const { page, limit } = query; + const { page = 1, limit = 20 } = query; const db = createDb(); const post = await db.query.posts.findFirst({ where: eq(posts.id, postId) }); @@ -315,8 +316,9 @@ export const feedRoutes = new Elysia({ prefix: '/feed' }) { params: z.object({ postId: z.coerce.number().int() }), query: z.object({ - page: z.coerce.number().int().min(1).optional().default(1), - limit: z.coerce.number().int().min(1).max(50).optional().default(20), + // Defaults applied in handler so Treaty types these as truly optional. + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(50).optional(), }), isAuthenticated: true, detail: { diff --git a/packages/api/src/routes/guides/index.ts b/packages/api/src/routes/guides/index.ts index c327787e3d..d95dbdd217 100644 --- a/packages/api/src/routes/guides/index.ts +++ b/packages/api/src/routes/guides/index.ts @@ -18,7 +18,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) .get( '/', async ({ query, request }) => { - const { page, limit, category } = query; + const { page = 1, limit = 20, category } = query; // Manually parse `sort[field]` / `sort[order]` from the raw query string. // Elysia's query parser leaves bracketed keys as-is rather than @@ -190,7 +190,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) .get( '/search', async ({ query }) => { - const { q, page, limit, category } = query; + const { q, page = 1, limit = 20, category } = query; if (!q || q.trim() === '') { return status(400, { error: 'Search query parameter q is required' }); } diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 1bdf287ca5..4993e695c3 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -76,7 +76,8 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) }, { query: z.object({ - includePublic: z.coerce.number().int().min(0).max(1).optional().default(0), + // Handler defaults this to 0; keep schema truly optional for clients. + includePublic: z.coerce.number().int().min(0).max(1).optional(), }), isAuthenticated: true, detail: { tags: ['Packs'], summary: 'List user packs', security: [{ bearerAuth: [] }] }, diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index 47b854582c..a0e5fba066 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -85,7 +85,8 @@ export const trailConditionRoutes = new Elysia() { query: z.object({ trailName: z.string().optional(), - limit: z.coerce.number().int().min(1).max(100).optional().default(50), + // Handler defaults to 50 via `?? 50`; keep schema truly optional. + limit: z.coerce.number().int().min(1).max(100).optional(), }), isAuthenticated: true, detail: { diff --git a/packages/api/src/schemas/ai.ts b/packages/api/src/schemas/ai.ts index 1b63c5ca22..ad2b660cb4 100644 --- a/packages/api/src/schemas/ai.ts +++ b/packages/api/src/schemas/ai.ts @@ -2,7 +2,8 @@ import { z } from 'zod'; export const RagSearchQuerySchema = z.object({ q: z.string().min(1), - limit: z.coerce.number().int().min(1).max(100).optional().default(5), + // Service applies its own default (5); keep schema truly optional. + limit: z.coerce.number().int().min(1).max(100).optional(), }); export const WebSearchQuerySchema = z.object({ diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index 7fcef4990b..9a464cd34b 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -127,8 +127,10 @@ const SortSchema = z.object({ }); export const CatalogItemsQuerySchema = z.object({ - page: z.coerce.number().int().positive().optional().default(1), - limit: z.coerce.number().int().min(1).max(100).optional().default(20), + // Defaults applied in the handler so Treaty types these as truly optional + // rather than required-with-default (which forces every caller to pass them). + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), q: z.string().optional(), category: z.string().optional(), // Eden Treaty serializes nested objects as JSON strings in query params. @@ -327,8 +329,9 @@ export const CatalogCategoriesResponseSchema = z.array(z.string()); export const VectorSearchQuerySchema = z.object({ q: z.string().min(1), - limit: z.coerce.number().int().min(1).max(50).optional().default(10), - offset: z.coerce.number().int().min(0).optional().default(0), + // Defaults applied in the handler — see CatalogItemsQuerySchema for rationale. + limit: z.coerce.number().int().min(1).max(50).optional(), + offset: z.coerce.number().int().min(0).optional(), }); export const SimilarItemSchema = CatalogItemSchema.extend({ diff --git a/packages/api/src/schemas/guides.ts b/packages/api/src/schemas/guides.ts index 25569e8dc9..f38a454aed 100644 --- a/packages/api/src/schemas/guides.ts +++ b/packages/api/src/schemas/guides.ts @@ -25,8 +25,9 @@ export const GuideDetailSchema = GuideSchema.extend({ }); export const GuidesQuerySchema = z.object({ - page: z.coerce.number().int().positive().optional().default(1), - limit: z.coerce.number().int().positive().optional().default(20), + // Defaults applied in the handler so Treaty types these as truly optional. + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().optional(), category: z.string().optional(), sort: z .object({ @@ -46,8 +47,9 @@ export const GuidesResponseSchema = z.object({ export const GuideSearchQuerySchema = z.object({ q: z.string().min(1), - page: z.coerce.number().int().positive().optional().default(1), - limit: z.coerce.number().int().positive().optional().default(20), + // Defaults applied in the handler so Treaty types these as truly optional. + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().optional(), category: z.string().optional(), }); diff --git a/packages/api/src/schemas/packs.ts b/packages/api/src/schemas/packs.ts index 3414d227da..bfa672fe23 100644 --- a/packages/api/src/schemas/packs.ts +++ b/packages/api/src/schemas/packs.ts @@ -123,7 +123,8 @@ export const ItemSuggestionsResponseSchema = z.object({ export const GapAnalysisRequestSchema = z.object({ destination: z.string().optional(), tripType: z.string().optional(), - duration: z.string().optional(), + // Duration is days. Coerce so JSON numbers and string form-data both work. + duration: z.coerce.number().int().positive().optional(), startDate: z.string().optional(), endDate: z.string().optional(), }); From 5d8ab306d9ffdcf928150467acf23574613d93db Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 15:34:55 -0600 Subject: [PATCH 09/30] =?UTF-8?q?=F0=9F=9A=B8=20cli,mcp:=20drop=20workarou?= =?UTF-8?q?nds=20now=20that=20API=20schemas=20are=20Treaty-friendly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the api schema relaxation: edge apps stop carrying default-with-default workarounds. cli/catalog search: drop `sort: undefined` placeholder cli/admin/analytics: drop forced `period: 'month' ?? args.period` and `range: 12 ?? args.range`; pass args through, server defaults cli/admin/packs: pass includeDeleted boolean directly (not '1'/'0') cli/admin/trails: same — boolean not string literal cli/packs/gap-analysis: duration is a number now; parse args.duration mcp/catalog search_gear_catalog: switch from `'sort[field]'`/`'sort[order]'` bracket notation to `sort: { field, order }` object — the API schema expects the JSON-preprocessed object form. Previous form was a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/admin/analytics.ts | 8 ++++---- packages/cli/src/commands/admin/packs.ts | 2 +- packages/cli/src/commands/admin/trails.ts | 2 +- packages/cli/src/commands/catalog/index.ts | 2 +- packages/cli/src/commands/packs/gap-analysis.ts | 2 +- packages/mcp/src/tools/catalog.ts | 3 +-- 6 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/admin/analytics.ts b/packages/cli/src/commands/admin/analytics.ts index ea19c8a095..3960a0ac69 100644 --- a/packages/cli/src/commands/admin/analytics.ts +++ b/packages/cli/src/commands/admin/analytics.ts @@ -18,8 +18,8 @@ const growthCmd = defineCommand({ const data = await runApi( client.admin.analytics.platform.growth.get({ query: { - period: (args.period ?? 'month') as 'day' | 'week' | 'month', - range: args.range ? Number.parseInt(args.range, 10) : 12, + period: args.period as 'day' | 'week' | 'month' | undefined, + range: args.range ? Number.parseInt(args.range, 10) : undefined, }, }), { action: 'admin growth analytics', requiresAdmin: true }, @@ -40,8 +40,8 @@ const activityCmd = defineCommand({ const data = await runApi( client.admin.analytics.platform.activity.get({ query: { - period: (args.period ?? 'month') as 'day' | 'week' | 'month', - range: args.range ? Number.parseInt(args.range, 10) : 12, + period: args.period as 'day' | 'week' | 'month' | undefined, + range: args.range ? Number.parseInt(args.range, 10) : undefined, }, }), { action: 'admin activity analytics', requiresAdmin: true }, diff --git a/packages/cli/src/commands/admin/packs.ts b/packages/cli/src/commands/admin/packs.ts index 0dc464483a..7e962d9af4 100644 --- a/packages/cli/src/commands/admin/packs.ts +++ b/packages/cli/src/commands/admin/packs.ts @@ -23,7 +23,7 @@ const listCmd = defineCommand({ q: args.q, limit: Number.parseInt(args.limit, 10), offset: Number.parseInt(args.offset, 10), - includeDeleted: args['include-deleted'] ? '1' : '0', + includeDeleted: args['include-deleted'], }, }), { action: 'admin list packs', requiresAdmin: true }, diff --git a/packages/cli/src/commands/admin/trails.ts b/packages/cli/src/commands/admin/trails.ts index 19834cbf65..8389ee5e9f 100644 --- a/packages/cli/src/commands/admin/trails.ts +++ b/packages/cli/src/commands/admin/trails.ts @@ -56,7 +56,7 @@ const reportsCmd = defineCommand({ q: args.q, limit: Number.parseInt(args.limit, 10), offset: Number.parseInt(args.offset, 10), - includeDeleted: args['include-deleted'] ? '1' : '0', + includeDeleted: args['include-deleted'], }, }), { action: 'admin list trail reports', requiresAdmin: true }, diff --git a/packages/cli/src/commands/catalog/index.ts b/packages/cli/src/commands/catalog/index.ts index c9aa8085a9..ca468db741 100644 --- a/packages/cli/src/commands/catalog/index.ts +++ b/packages/cli/src/commands/catalog/index.ts @@ -20,7 +20,7 @@ const searchCmd = defineCommand({ const page = Number.parseInt(args.page, 10); const data = await runApi( client.catalog.get({ - query: { q: args.q, category: args.category, limit, page, sort: undefined }, + query: { q: args.q, category: args.category, limit, page }, }), { action: 'search catalog' }, ); diff --git a/packages/cli/src/commands/packs/gap-analysis.ts b/packages/cli/src/commands/packs/gap-analysis.ts index bbeceeac49..d4916eeada 100644 --- a/packages/cli/src/commands/packs/gap-analysis.ts +++ b/packages/cli/src/commands/packs/gap-analysis.ts @@ -30,7 +30,7 @@ export default defineCommand({ client.packs({ packId: args.id })['gap-analysis'].post({ destination: args.destination, tripType: args['trip-type'], - duration: args.duration, + duration: Number.parseInt(args.duration, 10), startDate: args.start, endDate: args.end, }), diff --git a/packages/mcp/src/tools/catalog.ts b/packages/mcp/src/tools/catalog.ts index db24033af3..60225be20a 100644 --- a/packages/mcp/src/tools/catalog.ts +++ b/packages/mcp/src/tools/catalog.ts @@ -36,8 +36,7 @@ export function registerCatalogTools(agent: AgentContext): void { category, limit, page, - 'sort[field]': sort_by, - 'sort[order]': sort_order, + sort: sort_by ? { field: sort_by, order: sort_order } : undefined, }, }), { action: 'search catalog' }, From ffc929ef751520ee4f967b7c4e8e819a27662af6 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 15:37:33 -0600 Subject: [PATCH 10/30] =?UTF-8?q?=E2=9C=A8=20api:=20GET=20/weather/by-name?= =?UTF-8?q?=3Fq=3DX=20=E2=80=94=20search=20+=20forecast=20in=20one=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the location query to the first match and returns its 10-day forecast, replacing the two-step (search → forecast) dance every weather caller had to do. MCP `get_weather` and CLI `weather forecast` collapse to a single Treaty call. Saves a round-trip and ~15 lines per consumer (no more "first match not found" / Array.isArray narrowing). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/weather.ts | 51 ++++++++++++++++++++++ packages/api/src/schemas/weather.ts | 4 ++ packages/cli/src/commands/weather/index.ts | 20 ++------- packages/mcp/src/tools/weather.ts | 25 +++-------- 4 files changed, 64 insertions(+), 36 deletions(-) diff --git a/packages/api/src/routes/weather.ts b/packages/api/src/routes/weather.ts index 1574f24ab8..264b0ac44a 100644 --- a/packages/api/src/routes/weather.ts +++ b/packages/api/src/routes/weather.ts @@ -3,6 +3,7 @@ import { type WeatherAPICurrentResponse, type WeatherAPIForecastResponse, type WeatherAPISearchResponse, + WeatherByNameQuerySchema, WeatherCoordinateQuerySchema, WeatherLocationIdSchema, WeatherSearchQuerySchema, @@ -170,4 +171,54 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) security: [{ bearerAuth: [] }], }, }, + ) + // Combined search + forecast — pass a location name and get the forecast + // directly. Saves the typical two-step (`/search` → `/forecast`) clients + // were doing. Returns 404 if no location matches. + .get( + '/by-name', + async ({ query }) => { + const { WEATHER_API_KEY } = getEnv(); + const q = query.q; + if (!q || q.length < 2) { + return status(400, { error: 'Query parameter q (≥ 2 chars) is required' }); + } + try { + const searchResponse = await fetch( + `${WEATHER_API_BASE_URL}/search.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, + ); + if (!searchResponse.ok) throw new Error(`API error: ${searchResponse.status}`); + const matches = (await searchResponse.json()) as WeatherAPISearchResponse; // safe-cast: WeatherAPI.com response shape matches this type + const first = Array.isArray(matches) ? matches[0] : null; + if (!first) { + return status(404, { error: `No weather location matched "${q}"` }); + } + const forecastResponse = await fetch( + `${WEATHER_API_BASE_URL}/forecast.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(`id:${first.id}`)}&days=10&aqi=yes&alerts=yes`, + ); + if (!forecastResponse.ok) throw new Error(`API error: ${forecastResponse.status}`); + const data = (await forecastResponse.json()) as WeatherAPIForecastResponse; // safe-cast: WeatherAPI.com response shape matches this type + return { + ...data, + location: { ...data.location, id: Number(first.id) }, + }; + } catch (error) { + console.error('Error fetching weather by name:', error); + return status(500, { + error: 'Internal server error', + code: 'WEATHER_BY_NAME_ERROR', + }); + } + }, + { + query: WeatherByNameQuerySchema, + isAuthenticated: true, + detail: { + tags: ['Weather'], + summary: 'Search and fetch forecast in one call', + description: + 'Resolve the location query to the first match and return its 10-day forecast.', + security: [{ bearerAuth: [] }], + }, + }, ); diff --git a/packages/api/src/schemas/weather.ts b/packages/api/src/schemas/weather.ts index 64446deef0..0f57b2025f 100644 --- a/packages/api/src/schemas/weather.ts +++ b/packages/api/src/schemas/weather.ts @@ -31,6 +31,10 @@ export const WeatherSearchQuerySchema = z.object({ q: z.string().optional(), }); +export const WeatherByNameQuerySchema = z.object({ + q: z.string().min(2), +}); + export const WeatherCoordinateQuerySchema = z.object({ lat: z.string(), lon: z.string(), diff --git a/packages/cli/src/commands/weather/index.ts b/packages/cli/src/commands/weather/index.ts index 3aca3bf072..3d728332b5 100644 --- a/packages/cli/src/commands/weather/index.ts +++ b/packages/cli/src/commands/weather/index.ts @@ -1,13 +1,11 @@ -import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; -import consola from 'consola'; import { getUserClient } from '../../api/client'; -import { requireAuth, runApi, tryApi } from '../../api/run'; +import { requireAuth, runApi } from '../../api/run'; const forecastCmd = defineCommand({ meta: { name: 'forecast', - description: 'Get a 10-day forecast for a location (name or lat,lon).', + description: 'Get a 10-day forecast for a named location (single API call).', }, args: { location: { type: 'positional', required: true, description: 'Location string' }, @@ -15,19 +13,9 @@ const forecastCmd = defineCommand({ async run({ args }) { await requireAuth(); const client = await getUserClient(); - const search = await tryApi(client.weather.search.get({ query: { q: args.location } })); - if (!search.ok) { - consola.error(`Could not search for "${args.location}" (HTTP ${search.status})`); - process.exit(1); - } - const first = Array.isArray(search.data) ? toRecord(search.data[0]) : null; - const id = first && 'id' in first ? first.id : null; - if (id == null) { - consola.error(`No matching weather location for "${args.location}".`); - process.exit(1); - } - const forecast = await runApi(client.weather.forecast.get({ query: { id: String(id) } }), { + const forecast = await runApi(client.weather['by-name'].get({ query: { q: args.location } }), { action: 'get weather forecast', + resourceHint: args.location, }); process.stdout.write(`${JSON.stringify(forecast, null, 2)}\n`); }, diff --git a/packages/mcp/src/tools/weather.ts b/packages/mcp/src/tools/weather.ts index a4c082ae35..e8890dbc2b 100644 --- a/packages/mcp/src/tools/weather.ts +++ b/packages/mcp/src/tools/weather.ts @@ -1,13 +1,9 @@ -import { isObject } from '@packrat/guards'; import { z } from 'zod'; -import { call, errMessage } from '../client'; +import { call } from '../client'; import type { AgentContext } from '../types'; export function registerWeatherTools(agent: AgentContext): void { - // ── Get weather (search + forecast combined) ────────────────────────────── - // Candidate for API thickening: a single GET /weather/by-name?q=... would - // collapse this two-step into one server call. - + // ── Get weather (single API call) ───────────────────────────────────────── agent.server.registerTool( 'get_weather', { @@ -20,22 +16,11 @@ export function registerWeatherTools(agent: AgentContext): void { .describe('Location to get weather for (city, trail, park, etc.)'), }, }, - async ({ location }) => { - const search = await agent.api.user.weather.search.get({ query: { q: location } }); - if (search.error || !search.data) { - return call(Promise.resolve(search), { - action: 'search weather location', - resourceHint: location, - }); - } - const first = Array.isArray(search.data) ? search.data[0] : null; - const id = isObject(first) && 'id' in first ? first.id : null; - if (id == null) return errMessage(`No weather location found for: ${location}`); - return call(agent.api.user.weather.forecast.get({ query: { id: String(id) } }), { + async ({ location }) => + call(agent.api.user.weather['by-name'].get({ query: { q: location } }), { action: 'fetch weather forecast', resourceHint: location, - }); - }, + }), ); // ── Search weather location ─────────────────────────────────────────────── From 4e79c181a166502c23f61d5c3f53c601c9e457e8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 15:40:16 -0600 Subject: [PATCH 11/30] =?UTF-8?q?=E2=9C=A8=20api:=20GET=20/packs/:packId/w?= =?UTF-8?q?eight-breakdown=20=E2=80=94=20per-category=20totals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-computed weight breakdown that returns total / base / worn / consumable grams plus a per-category aggregation sorted heaviest first. Removes the 30-line client-side walk MCP's analyze_pack_weight was doing locally — it's now a single Treaty call. - New `computePackBreakdown(pack)` in utils/compute-pack.ts - PackWeightBreakdownSchema + PackCategoryBreakdownSchema in schemas/packs.ts - Route uses the same db.query.packs.findFirst + items pattern as the GET /:packId detail route - MCP `analyze_pack_weight` collapses to a one-line passthrough Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/packs/index.ts | 36 ++++++++++++- packages/api/src/schemas/packs.ts | 18 +++++++ packages/api/src/utils/compute-pack.ts | 72 ++++++++++++++++++++++++++ packages/mcp/src/tools/packs.ts | 67 +++--------------------- 4 files changed, 133 insertions(+), 60 deletions(-) diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 4993e695c3..eb33ef34b9 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -20,7 +20,11 @@ import { } from '@packrat/api/schemas/packs'; import { ImageDetectionService, PackService } from '@packrat/api/services'; import { generateEmbedding } from '@packrat/api/services/embeddingService'; -import { computePacksWeights, computePackWeights } from '@packrat/api/utils/compute-pack'; +import { + computePackBreakdown, + computePacksWeights, + computePackWeights, +} from '@packrat/api/utils/compute-pack'; import { getPackDetails } from '@packrat/api/utils/DbUtils'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; @@ -248,6 +252,36 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) }, ) + // Weight breakdown — total/base/worn/consumable + per-category aggregation. + // Edge apps were computing this by walking pack.items locally; centralising + // here keeps MCP/CLI tools as one-line passthroughs. + .get( + '/:packId/weight-breakdown', + async ({ params }) => { + const db = createDb(); + try { + const pack = await db.query.packs.findFirst({ + where: eq(packs.id, params.packId), + with: { items: { where: eq(packItems.deleted, false) } }, + }); + if (!pack) return status(404, { error: 'Pack not found' }); + return computePackBreakdown(pack); + } catch (error) { + console.error('Error computing pack breakdown:', error); + return status(500, { error: 'Failed to compute breakdown' }); + } + }, + { + params: z.object({ packId: z.string() }), + isAuthenticated: true, + detail: { + tags: ['Packs'], + summary: 'Per-category weight breakdown', + security: [{ bearerAuth: [] }], + }, + }, + ) + // Update pack .put( '/:packId', diff --git a/packages/api/src/schemas/packs.ts b/packages/api/src/schemas/packs.ts index bfa672fe23..b18d63028f 100644 --- a/packages/api/src/schemas/packs.ts +++ b/packages/api/src/schemas/packs.ts @@ -120,6 +120,24 @@ export const ItemSuggestionsResponseSchema = z.object({ ), }); +export const PackCategoryBreakdownSchema = z.object({ + category: z.string(), + totalGrams: z.number(), + totalLbs: z.number(), + itemCount: z.number(), + items: z.array(z.string()), +}); + +export const PackWeightBreakdownSchema = z.object({ + packId: z.string(), + totalGrams: z.number(), + baseGrams: z.number(), + wornGrams: z.number(), + consumableGrams: z.number(), + itemCount: z.number(), + byCategory: z.array(PackCategoryBreakdownSchema), +}); + export const GapAnalysisRequestSchema = z.object({ destination: z.string().optional(), tripType: z.string().optional(), diff --git a/packages/api/src/utils/compute-pack.ts b/packages/api/src/utils/compute-pack.ts index da16adad10..ae808d3a3f 100644 --- a/packages/api/src/utils/compute-pack.ts +++ b/packages/api/src/utils/compute-pack.ts @@ -34,3 +34,75 @@ export const computePacksWeights = ( preferredUnit: WeightUnit = 'g', ): (PackWithItems & { baseWeight: number; totalWeight: number })[] => packs.map((pack) => computePackWeights(pack, preferredUnit)); + +export interface PackCategoryBreakdown { + category: string; + totalGrams: number; + totalLbs: number; + itemCount: number; + items: string[]; +} + +export interface PackWeightBreakdown { + packId: string; + totalGrams: number; + baseGrams: number; + wornGrams: number; + consumableGrams: number; + itemCount: number; + byCategory: PackCategoryBreakdown[]; +} + +const GRAMS_PER_LB = 453.592; + +/** + * Full weight breakdown including worn/consumable totals and a per-category + * grouping sorted heaviest first. Replaces ad-hoc breakdowns the edge apps + * were computing client-side. + */ +export const computePackBreakdown = (pack: PackWithItems): PackWeightBreakdown => { + if (!pack.items) { + throw new Error(`Pack with ID ${pack.id} has no items`); + } + + let totalGrams = 0; + let baseGrams = 0; + let wornGrams = 0; + let consumableGrams = 0; + const byCategory: Record = {}; + + for (const item of pack.items) { + const itemGrams = normalize(item.weight, parseWeightUnit(item.weightUnit)) * item.quantity; + totalGrams += itemGrams; + if (item.worn) wornGrams += itemGrams; + if (item.consumable) consumableGrams += itemGrams; + if (!item.worn && !item.consumable) baseGrams += itemGrams; + + const cat = item.category || 'Uncategorized'; + const entry = byCategory[cat] ?? { + category: cat, + totalGrams: 0, + totalLbs: 0, + itemCount: 0, + items: [], + }; + entry.totalGrams += itemGrams; + entry.itemCount += item.quantity; + entry.items.push(`${item.name} (${item.weight}${item.weightUnit ?? 'g'} × ${item.quantity})`); + byCategory[cat] = entry; + } + + for (const entry of Object.values(byCategory)) { + entry.totalLbs = Math.round((entry.totalGrams / GRAMS_PER_LB) * 100) / 100; + } + + return { + packId: pack.id, + totalGrams: Math.round(totalGrams), + baseGrams: Math.round(baseGrams), + wornGrams: Math.round(wornGrams), + consumableGrams: Math.round(consumableGrams), + itemCount: pack.items.length, + byCategory: Object.values(byCategory).sort((a, b) => b.totalGrams - a.totalGrams), + }; +}; diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index c22bf25a3b..c38ee85c8c 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -1,23 +1,8 @@ import { z } from 'zod'; -import { call, nowIso, ok, shortId } from '../client'; +import { call, nowIso, shortId } from '../client'; import { ItemCategory, PackCategory } from '../enums'; import type { AgentContext } from '../types'; -interface PackDetailResponse { - items?: Array<{ - name: string; - category: string; - weight: number; - quantity: number; - worn: boolean; - consumable: boolean; - }>; - totalWeight?: number; - baseWeight?: number; - wornWeight?: number; - consumableWeight?: number; -} - export function registerPackTools(agent: AgentContext): void { // ── List packs ──────────────────────────────────────────────────────────── @@ -351,55 +336,19 @@ export function registerPackTools(agent: AgentContext): void { }, ); - // ── Pack weight analysis ────────────────────────────────────────────────── - // The API already returns totalWeight/baseWeight/wornWeight/consumableWeight - // on a pack detail but does NOT return a per-category breakdown. Candidate - // for API thickening: GET /packs/:packId/weight-breakdown. - + // ── Pack weight analysis (server-computed breakdown) ───────────────────── agent.server.registerTool( 'analyze_pack_weight', { description: - 'Get a detailed weight breakdown for a pack by category. Returns base weight, worn weight, consumable weight, and total weight with per-category summaries. Useful for identifying the heaviest items and optimization opportunities.', + 'Get a detailed weight breakdown for a pack: total / base / worn / consumable grams plus a per-category aggregation sorted heaviest first.', inputSchema: { pack_id: z.string().describe('The pack ID to analyze') }, }, - async ({ pack_id }) => { - const { data, error, status } = await agent.api.user.packs({ packId: pack_id }).get(); - if (error || !data) { - return call(Promise.resolve({ data: null, error, status }), { - action: 'analyze pack weight', - resourceHint: `pack ${pack_id}`, - }); - } - const pack = data as unknown as PackDetailResponse; // safe-cast: API returns the pack detail shape used below - const byCategory: Record = {}; - const items = pack.items ?? []; - for (const item of items) { - const cat = item.category || 'Uncategorized'; - const entry = byCategory[cat] ?? { items: [], totalGrams: 0, count: 0 }; - entry.items.push(`${item.name} (${item.weight}g × ${item.quantity})`); - entry.totalGrams += item.weight * item.quantity; - entry.count += item.quantity; - byCategory[cat] = entry; - } - return ok({ - packId: pack_id, - totalWeight: pack.totalWeight ?? 0, - baseWeight: pack.baseWeight ?? 0, - wornWeight: pack.wornWeight ?? 0, - consumableWeight: pack.consumableWeight ?? 0, - itemCount: items.length, - byCategory: Object.entries(byCategory) - .sort((a, b) => b[1].totalGrams - a[1].totalGrams) - .map(([category, stats]) => ({ - category, - totalGrams: stats.totalGrams, - totalLbs: (stats.totalGrams / 453.592).toFixed(2), - itemCount: stats.count, - items: stats.items, - })), - }); - }, + async ({ pack_id }) => + call(agent.api.user.packs({ packId: pack_id })['weight-breakdown'].get(), { + action: 'analyze pack weight', + resourceHint: `pack ${pack_id}`, + }), ); // ── Gap analysis ────────────────────────────────────────────────────────── From 2fcc4c485b08c41867c222c6eb8ae1a36ab31389 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 15:42:02 -0600 Subject: [PATCH 12/30] =?UTF-8?q?=E2=9C=A8=20api:=20POST=20/catalog/compar?= =?UTF-8?q?e=20=E2=80=94=20multi-item=20side-by-side=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepts { ids: number[] } (2–10) and returns each item's comparison row plus the lightestId / cheapestId / highestRatedId leaders. Resolved with a single SQL inArray() query — no per-item round-trips. MCP `compare_gear_items` collapses to a one-line passthrough: drops ~50 lines of N-parallel-GET + client-side sort logic. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/catalog/index.ts | 60 ++++++++++++++++++++++++ packages/api/src/schemas/catalog.ts | 24 ++++++++++ packages/mcp/src/tools/catalog.ts | 59 +++-------------------- 3 files changed, 90 insertions(+), 53 deletions(-) diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index 21ffb876fe..0ad19d3166 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -2,6 +2,7 @@ import { createDb } from '@packrat/api/db'; import { catalogItems, etlJobs, packItems } from '@packrat/api/db/schema'; import { apiKeyAuthPlugin, authPlugin } from '@packrat/api/middleware/auth'; import { + CatalogCompareRequestSchema, CatalogItemsQuerySchema, CreateCatalogItemRequestSchema, UpdateCatalogItemRequestSchema, @@ -22,6 +23,7 @@ import { eq, getTableColumns, gt, + inArray, isNotNull, isNull, ne, @@ -132,6 +134,64 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) }, ) + // -- Compare items side-by-side (static path, register before /:id) + .post( + '/compare', + async ({ body }) => { + const db = createDb(); + const { ids } = body; + const items = await db + .select({ + id: catalogItems.id, + name: catalogItems.name, + brand: catalogItems.brand, + weight: catalogItems.weight, + weightUnit: catalogItems.weightUnit, + price: catalogItems.price, + ratingValue: catalogItems.ratingValue, + ratingCount: catalogItems.ratingCount, + productUrl: catalogItems.productUrl, + categories: catalogItems.categories, + }) + .from(catalogItems) + .where(inArray(catalogItems.id, ids)); + + if (items.length === 0) { + return status(404, { error: 'No catalog items matched the supplied IDs' }); + } + + const rank = ( + key: K, + order: 'asc' | 'desc', + ): number | null => { + const ranked = [...items] + .filter((it) => it[key] != null) + .sort((a, b) => { + const av = Number(a[key]); + const bv = Number(b[key]); + return order === 'asc' ? av - bv : bv - av; + }); + return ranked[0]?.id ?? null; + }; + + return { + items, + lightestId: rank('weight', 'asc'), + cheapestId: rank('price', 'asc'), + highestRatedId: rank('ratingValue', 'desc'), + }; + }, + { + body: CatalogCompareRequestSchema, + isAuthenticated: true, + detail: { + tags: ['Catalog'], + summary: 'Compare 2–10 catalog items side-by-side', + security: [{ bearerAuth: [] }], + }, + }, + ) + // -- Embeddings stats .get( '/embeddings-stats', diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index 9a464cd34b..9aa119d8a5 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -345,3 +345,27 @@ export const VectorSearchResponseSchema = z.object({ offset: z.number(), nextOffset: z.number(), }); + +export const CatalogCompareRequestSchema = z.object({ + ids: z.array(z.number().int()).min(2).max(10), +}); + +export const CatalogCompareRowSchema = z.object({ + id: z.number().int(), + name: z.string(), + brand: z.string().nullable(), + weight: z.number().nullable(), + weightUnit: z.string().nullable(), + price: z.number().nullable(), + ratingValue: z.number().nullable(), + ratingCount: z.number().nullable(), + productUrl: z.string().nullable(), + categories: z.array(z.string()).nullable(), +}); + +export const CatalogCompareResponseSchema = z.object({ + items: z.array(CatalogCompareRowSchema), + lightestId: z.number().int().nullable(), + cheapestId: z.number().int().nullable(), + highestRatedId: z.number().int().nullable(), +}); diff --git a/packages/mcp/src/tools/catalog.ts b/packages/mcp/src/tools/catalog.ts index 60225be20a..ec6cfa7d5c 100644 --- a/packages/mcp/src/tools/catalog.ts +++ b/packages/mcp/src/tools/catalog.ts @@ -172,61 +172,14 @@ export function registerCatalogTools(agent: AgentContext): void { 'compare_gear_items', { description: - 'Compare multiple gear items side-by-side on weight, price, and rating. Provide 2–5 catalog item IDs.', + 'Compare multiple gear items side-by-side on weight, price, and rating. Provide 2–10 catalog item IDs.', inputSchema: { - item_ids: z.array(z.number().int()).min(2).max(5), + item_ids: z.array(z.number().int()).min(2).max(10), }, }, - async ({ item_ids }) => { - const responses = await Promise.all( - item_ids.map((id) => agent.api.user.catalog({ id: String(id) }).get()), - ); - const firstError = responses.find((r) => r.error || !r.data); - if (firstError) { - return call( - Promise.resolve({ - data: null, - error: firstError.error, - status: firstError.status, - }), - { action: 'compare catalog items' }, - ); - } - const comparison = responses.map((r) => { - // safe-cast: catalog item response is a JSON object; display only - const it = (r.data ?? {}) as Record; - return { - id: it.id, - name: it.name, - brand: it.brand, - category: it.category, - weightGrams: it.weight, - priceCents: it.price, - rating: it.ratingValue, - reviewCount: it.ratingCount, - productUrl: it.productUrl, - }; - }); - comparison.sort( - (a, b) => (Number(a.weightGrams) || 999_999) - (Number(b.weightGrams) || 999_999), - ); - return call( - Promise.resolve({ - data: { - items: comparison, - lightest: comparison[0]?.name, - cheapest: [...comparison].sort( - (a, b) => (Number(a.priceCents) || 999_999) - (Number(b.priceCents) || 999_999), - )[0]?.name, - highestRated: [...comparison].sort( - (a, b) => (Number(b.rating) || 0) - (Number(a.rating) || 0), - )[0]?.name, - }, - error: null, - status: 200, - }), - { action: 'compare catalog items' }, - ); - }, + async ({ item_ids }) => + call(agent.api.user.catalog.compare.post({ ids: item_ids }), { + action: 'compare catalog items', + }), ); } From 089af14cb599ef84d5a9c7c349043ce574713669 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 15:44:00 -0600 Subject: [PATCH 13/30] =?UTF-8?q?=E2=9C=A8=20api:=20POST=20/packs/:packId/?= =?UTF-8?q?items/from-catalog=20=E2=80=94=20hydrate=20item=20from=20catalo?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lean catalog-linked pack item creation. Caller only supplies: { catalogItemId, quantity?, notes?, consumable?, worn?, category? } Server resolves the catalog row, copies name/description/weight/weightUnit/ image/embedding/(default) category, mints the pack item ID, and inserts. Falls back to the catalog's first category when no override is supplied. MCP gets a corresponding `add_pack_item_from_catalog` tool that's literally just the catalog ID + per-pack fields — no more duplicating weight, weightUnit, name, category at every call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/packs/index.ts | 90 ++++++++++++++++++++++++++ packages/mcp/src/tools/packs.ts | 32 +++++++++ 2 files changed, 122 insertions(+) diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index eb33ef34b9..5130c36e3a 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -54,6 +54,22 @@ const AddPackItemBodySchema = CreatePackItemRequestSchema.extend({ id: z.string(), }); +// Lean payload for /items/from-catalog. Name/weight/weightUnit/category get +// hydrated server-side from the catalog row. +const AddPackItemFromCatalogSchema = z.object({ + catalogItemId: z.number().int().positive(), + quantity: z.number().int().positive().optional(), + notes: z.string().optional(), + consumable: z.boolean().optional(), + worn: z.boolean().optional(), + // Optional override — usually the catalog category is fine. + category: z.string().optional(), +}); + +const STRIP_HYPHENS = /-/g; +const shortPackItemId = (): string => + `i_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; + export const packsRoutes = new Elysia({ prefix: '/packs' }) .use(authPlugin) .use(adminAuthPlugin) @@ -699,6 +715,80 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s }, ) + // Add item from catalog — server hydrates name/weight/category from the + // catalog row so the caller only supplies the link plus per-pack metadata + // (quantity, notes, worn, consumable). + .post( + '/:packId/items/from-catalog', + async ({ params, body, user }) => { + const db = createDb(); + const packId = params.packId; + + const pack = await db.query.packs.findFirst({ + where: and(eq(packs.id, packId), eq(packs.userId, user.userId)), + columns: { id: true }, + }); + if (!pack) return status(404, { error: 'Pack not found' }); + + const catalog = await db.query.catalogItems.findFirst({ + where: eq(catalogItems.id, body.catalogItemId), + }); + if (!catalog) { + return status(404, { error: `Catalog item ${body.catalogItemId} not found` }); + } + + const id = shortPackItemId(); + const now = new Date(); + const [newItem] = await db + .insert(packItems) + .values({ + id, + packId, + catalogItemId: catalog.id, + name: catalog.name, + description: catalog.description ?? null, + weight: catalog.weight ?? 0, + weightUnit: catalog.weightUnit ?? 'g', + quantity: body.quantity ?? 1, + category: body.category ?? catalog.categories?.[0] ?? 'Uncategorized', + consumable: body.consumable ?? false, + worn: body.worn ?? false, + image: catalog.images?.[0] ?? null, + notes: body.notes ?? null, + userId: user.userId, + embedding: catalog.embedding, + localCreatedAt: now, + localUpdatedAt: now, + } as NewPackItem) // safe-cast: object literal matches NewPackItem; embedding field uses the narrower type + .returning(); + + await db.update(packs).set({ updatedAt: new Date() }).where(eq(packs.id, packId)); + + if (!newItem) return status(400, { error: 'Failed to create item' }); + + return status(201, { + ...newItem, + consumable: newItem.consumable ?? false, + worn: newItem.worn ?? false, + deleted: newItem.deleted ?? false, + createdAt: newItem.createdAt.toISOString(), + updatedAt: newItem.updatedAt.toISOString(), + embedding: undefined, + templateItemId: newItem.templateItemId ?? null, + }); + }, + { + params: z.object({ packId: z.string() }), + body: AddPackItemFromCatalogSchema, + isAuthenticated: true, + detail: { + tags: ['Pack Items'], + summary: 'Add a catalog item to a pack', + security: [{ bearerAuth: [] }], + }, + }, + ) + // Get single item .get( '/items/:itemId', diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index c38ee85c8c..9e9e886336 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -213,6 +213,38 @@ export function registerPackTools(agent: AgentContext): void { }, ); + // ── Add a catalog item to a pack (lean) ────────────────────────────────── + // Server hydrates name/weight/weightUnit/category from the catalog row. + + agent.server.registerTool( + 'add_pack_item_from_catalog', + { + description: + 'Add a catalog item to a pack. Server copies name/weight/category from the catalog row; you just supply the catalog ID and per-pack metadata (quantity, notes, worn, consumable).', + inputSchema: { + pack_id: z.string(), + catalog_item_id: z.number().int().positive(), + quantity: z.number().int().min(1).optional(), + notes: z.string().optional(), + is_consumable: z.boolean().optional(), + is_worn: z.boolean().optional(), + category: z.string().optional().describe('Override catalog category if needed'), + }, + }, + async ({ pack_id, catalog_item_id, quantity, notes, is_consumable, is_worn, category }) => + call( + agent.api.user.packs({ packId: pack_id }).items['from-catalog'].post({ + catalogItemId: catalog_item_id, + quantity, + notes, + consumable: is_consumable, + worn: is_worn, + category, + }), + { action: 'add catalog item to pack', resourceHint: `pack ${pack_id}` }, + ), + ); + // ── Update pack item ────────────────────────────────────────────────────── agent.server.registerTool( From df0cfac92e97f397e312bf8c0b0308e42bcf3126 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 16:18:59 -0600 Subject: [PATCH 14/30] =?UTF-8?q?=E2=9C=A8=20api:=20server-side=20ID=20min?= =?UTF-8?q?ting=20(optional,=20preserves=20offline-first)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every create endpoint now accepts an optional id; the server mints one when absent. Offline-first stores (mobile, Legend State) keep supplying their own client-side IDs so sync-conflict resolution still works — only lean callers (MCP, CLI, web) drop the redundant minting. New shared helper: packages/api/src/utils/ids.ts → `mintId(prefix)`. Format unchanged: `_<12-hex>`, e.g. `p_a1b2c3d4e5f6`. Affected endpoints: packs POST / id → optional, server mints p_ packs/items POST /:packId/items id → optional, server mints i_ packs/weight POST /:packId/weight-history id → optional, server mints w_ trips POST / id → optional, server mints t_ pack-templates POST / id → optional, server mints pt_ pack-templates POST /:templateId/items id → optional, server mints pti_ trail-conditions POST / id → optional, server mints tcr_ Also consolidated the duplicate `STRIP_HYPHENS` regex + inline UUID-slice patterns in packTemplates and packs/items handlers — they all go through mintId() now. MCP & CLI stop minting client-side for these create paths (server does it). shortId helpers stay exported in CLI/MCP for the rare offline-first callers that might still want them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/src/routes/packTemplates/index.ts | 10 +++---- packages/api/src/routes/packs/index.ts | 24 ++++++++--------- .../api/src/routes/trailConditions/reports.ts | 13 +++++++--- packages/api/src/routes/trips/index.ts | 9 ++++--- packages/api/src/schemas/packTemplates.ts | 6 +++-- packages/api/src/utils/ids.ts | 19 ++++++++++++++ packages/cli/src/commands/packs/create.ts | 6 ++--- packages/cli/src/commands/templates/index.ts | 4 +-- packages/cli/src/commands/trips/index.ts | 4 +-- packages/mcp/src/tools/packTemplates.ts | 13 +++------- packages/mcp/src/tools/packs.ts | 26 ++++++------------- packages/mcp/src/tools/trail-conditions.ts | 4 +-- packages/mcp/src/tools/trips.ts | 4 +-- 13 files changed, 73 insertions(+), 69 deletions(-) create mode 100644 packages/api/src/utils/ids.ts diff --git a/packages/api/src/routes/packTemplates/index.ts b/packages/api/src/routes/packTemplates/index.ts index 56fbab9342..6c03f60dd1 100644 --- a/packages/api/src/routes/packTemplates/index.ts +++ b/packages/api/src/routes/packTemplates/index.ts @@ -12,6 +12,7 @@ import { } from '@packrat/api/schemas/packTemplates'; import { CatalogService } from '@packrat/api/services/catalogService'; import { getEnv } from '@packrat/api/utils/env-validation'; +import { mintId } from '@packrat/api/utils/ids'; import { assertDefined } from '@packrat/guards'; import { generateObject } from 'ai'; import { and, eq, or, sql } from 'drizzle-orm'; @@ -24,7 +25,6 @@ import { z } from 'zod'; // --------------------------------------------------------------------------- const QUERY_STRIP_RE = /[?&].*$/; -const STRIP_HYPHENS = /-/g; function generateContentIdFromUrl(url: string): string { const normalizedUrl = url.toLowerCase().replace(QUERY_STRIP_RE, ''); @@ -174,7 +174,7 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) const [newTemplate] = await db .insert(packTemplates) .values({ - id: data.id, + id: data.id ?? mintId('pt'), userId: user.userId, name: data.name, description: data.description, @@ -333,7 +333,7 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) : { items: [] as never[] }; const now = new Date(); - const templateId = `pt_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 21)}`; + const templateId = mintId('pt'); const { newTemplate, insertedItems } = await db.transaction(async (tx) => { const [createdTemplate] = await tx @@ -358,7 +358,7 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) const itemRecords = analysis.items.map((detected, index) => { const catalogMatches = batchResult.items[index] ?? []; const bestMatch = catalogMatches[0]; - const itemId = `pti_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 21)}`; + const itemId = mintId('pti'); return { id: itemId, @@ -690,7 +690,7 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) const [newItem] = await db .insert(packTemplateItems) .values({ - id: data.id, + id: data.id ?? mintId('pti'), packTemplateId: templateId, name: data.name, description: data.description, diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 5130c36e3a..2c31b64958 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -29,6 +29,7 @@ import { getPackDetails } from '@packrat/api/utils/DbUtils'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; import { getPresignedUrl } from '@packrat/api/utils/getPresignedUrl'; +import { mintId } from '@packrat/api/utils/ids'; import { and, cosineDistance, @@ -44,14 +45,16 @@ import { import { Elysia, status } from 'elysia'; import { z } from 'zod'; +// id is optional so server can mint for lean (MCP/CLI/web) callers while +// offline-first stores (mobile) continue supplying their own client-side IDs. const CreatePackBodySchema = CreatePackRequestSchema.extend({ - id: z.string(), + id: z.string().optional(), localCreatedAt: z.string(), localUpdatedAt: z.string(), }); const AddPackItemBodySchema = CreatePackItemRequestSchema.extend({ - id: z.string(), + id: z.string().optional(), }); // Lean payload for /items/from-catalog. Name/weight/weightUnit/category get @@ -66,10 +69,6 @@ const AddPackItemFromCatalogSchema = z.object({ category: z.string().optional(), }); -const STRIP_HYPHENS = /-/g; -const shortPackItemId = (): string => - `i_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; - export const packsRoutes = new Elysia({ prefix: '/packs' }) .use(authPlugin) .use(adminAuthPlugin) @@ -111,8 +110,7 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) const db = createDb(); const data = body; - const packId = data.id as string; - if (!packId) return status(400, { error: 'Pack ID is required' }); + const packId = data.id ?? mintId('p'); // Zod validates all fields at runtime; cast through the Standard Schema // inference gap so drizzle's insert accepts the values. @@ -440,7 +438,7 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) const packWeightHistoryEntry = await db .insert(packWeightHistory) .values({ - id: data.id, + id: data.id ?? mintId('w'), packId: params.packId, userId: user.userId, weight: data.weight, @@ -460,7 +458,7 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) { params: z.object({ packId: z.string() }), body: z.object({ - id: z.string(), + id: z.string().optional(), weight: z.number(), localCreatedAt: z.string().datetime(), }), @@ -659,7 +657,7 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s getEnv(); if (!OPENAI_API_KEY) return status(400, { error: 'OpenAI API key not configured' }); - if (!data.id) return status(400, { error: 'Item ID is required' }); + const itemId = data.id ?? mintId('i'); const embeddingText = getEmbeddingText(data); const embedding = await generateEmbedding({ @@ -674,7 +672,7 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s const [newItem] = await db .insert(packItems) .values({ - id: data.id, + id: itemId, packId, catalogItemId: data.catalogItemId ? Number(data.catalogItemId) : null, name: data.name, @@ -737,7 +735,7 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s return status(404, { error: `Catalog item ${body.catalogItemId} not found` }); } - const id = shortPackItemId(); + const id = mintId('i'); const now = new Date(); const [newItem] = await db .insert(packItems) diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index a0e5fba066..5ac7f5bd6a 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -2,6 +2,7 @@ import { createDb } from '@packrat/api/db'; import type { NewTrailConditionReport } from '@packrat/api/db/schema'; import { trailConditionReports } from '@packrat/api/db/schema'; import { authPlugin } from '@packrat/api/middleware/auth'; +import { mintId } from '@packrat/api/utils/ids'; import { and, desc, eq, gte, ilike, type SQL } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { z } from 'zod'; @@ -12,7 +13,9 @@ const LIKE_ESCAPE_PERCENT = /%/g; const LIKE_ESCAPE_UNDERSCORE = /_/g; const CreateReportRequestSchema = z.object({ - id: z.string().describe('Client-generated report ID'), + // id optional — server mints if absent (lean callers). Offline-first + // stores keep supplying client-side IDs for sync. + id: z.string().optional().describe('Client-generated report ID; server mints when absent'), trailName: z.string().min(1), trailRegion: z.string().optional().nullable(), surface: z.enum(['paved', 'gravel', 'dirt', 'rocky', 'snow', 'mud']), @@ -101,12 +104,13 @@ export const trailConditionRoutes = new Elysia() async ({ body, user }) => { const db = createDb(); const data = body; + const reportId = data.id ?? mintId('tcr'); try { const [newReport] = await db .insert(trailConditionReports) .values({ - id: data.id, + id: reportId, trailName: data.trailName, trailRegion: data.trailRegion ?? null, surface: data.surface, @@ -129,7 +133,10 @@ export const trailConditionRoutes = new Elysia() return toReportResponse(newReport); } catch (error) { const pgCode = (error as { code?: string })?.code; - if (pgCode === '23505') { + // 23505 is the unique-violation path: client-supplied id already in + // use. Server-minted ids are statistically collision-free, so this + // only matters when the caller passed one. + if (pgCode === '23505' && data.id) { const existing = await db.query.trailConditionReports.findFirst({ where: and( eq(trailConditionReports.id, data.id), diff --git a/packages/api/src/routes/trips/index.ts b/packages/api/src/routes/trips/index.ts index 8d927714a7..72eeeb9b81 100644 --- a/packages/api/src/routes/trips/index.ts +++ b/packages/api/src/routes/trips/index.ts @@ -1,6 +1,7 @@ import { createDb } from '@packrat/api/db'; import { type Trip, trips } from '@packrat/api/db/schema'; import { authPlugin } from '@packrat/api/middleware/auth'; +import { mintId } from '@packrat/api/utils/ids'; import { and, eq } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { z } from 'zod'; @@ -15,7 +16,9 @@ const LocationSchema = z .optional(); const CreateTripRequestSchema = z.object({ - id: z.string(), + // id optional so server can mint for lean callers; offline-first stores + // (mobile) keep supplying client-side IDs for sync. + id: z.string().optional(), name: z.string().min(1), description: z.string().optional().nullable(), location: LocationSchema, @@ -68,13 +71,13 @@ export const tripsRoutes = new Elysia({ prefix: '/trips' }) const db = createDb(); const data = body; - if (!data.id) return status(400, { error: 'Trip ID is required' }); + const tripId = data.id ?? mintId('t'); try { const [newTrip] = await db .insert(trips) .values({ - id: data.id, + id: tripId, userId: user.userId, name: data.name, description: data.description ?? null, diff --git a/packages/api/src/schemas/packTemplates.ts b/packages/api/src/schemas/packTemplates.ts index 6ffe2de886..4a2dcd9a12 100644 --- a/packages/api/src/schemas/packTemplates.ts +++ b/packages/api/src/schemas/packTemplates.ts @@ -54,7 +54,8 @@ export const PackTemplateWithItemsSchema = PackTemplateSchema.extend({ }); export const CreatePackTemplateRequestSchema = z.object({ - id: z.string(), + // id optional so the server can mint for lean callers. + id: z.string().optional(), name: z.string().min(1).max(255), description: z.string().optional(), category: z.string().min(1), @@ -77,7 +78,8 @@ export const UpdatePackTemplateRequestSchema = z.object({ }); export const CreatePackTemplateItemRequestSchema = z.object({ - id: z.string(), + // id optional so the server can mint for lean callers. + id: z.string().optional(), name: z.string().min(1).max(255), description: z.string().optional(), weight: z.number().min(0), diff --git a/packages/api/src/utils/ids.ts b/packages/api/src/utils/ids.ts new file mode 100644 index 0000000000..597b1edb72 --- /dev/null +++ b/packages/api/src/utils/ids.ts @@ -0,0 +1,19 @@ +/** + * Server-side ID minting for offline-first stores. + * + * The mobile / desktop stores supply their own IDs so Legend State can + * write rows before sync — those client-supplied IDs are kept as-is. + * Lean callers (MCP, CLI, web) can omit `id` and let the server mint one + * with the right prefix here, which keeps the format stable across both + * sources. + * + * Format: `_<12-hex>`, e.g. `p_a1b2c3d4e5f6`. + */ + +const STRIP_HYPHENS = /-/g; + +const SHORT_LENGTH = 12; + +export function mintId(prefix: string): string { + return `${prefix}_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, SHORT_LENGTH)}`; +} diff --git a/packages/cli/src/commands/packs/create.ts b/packages/cli/src/commands/packs/create.ts index d51119a173..b2d4b300b9 100644 --- a/packages/cli/src/commands/packs/create.ts +++ b/packages/cli/src/commands/packs/create.ts @@ -2,7 +2,7 @@ import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; -import { nowIso, shortId } from '../../api/ids'; +import { nowIso } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; export default defineCommand({ @@ -22,7 +22,6 @@ export default defineCommand({ async run({ args }) { await requireAuth(); const client = await getUserClient(); - const id = shortId('p'); const now = nowIso(); const tags = args.tags ? args.tags @@ -32,7 +31,6 @@ export default defineCommand({ : undefined; const pack = await runApi( client.packs.post({ - id, name: args.name, description: args.description, category: args.category, @@ -43,6 +41,6 @@ export default defineCommand({ }), { action: 'create pack' }, ); - consola.success(`Created pack ${toRecord(pack).id ?? id}`); + consola.success(`Created pack ${toRecord(pack).id ?? '(server-minted id)'}`); }, }); diff --git a/packages/cli/src/commands/templates/index.ts b/packages/cli/src/commands/templates/index.ts index 6ea3450daf..d81319e14f 100644 --- a/packages/cli/src/commands/templates/index.ts +++ b/packages/cli/src/commands/templates/index.ts @@ -1,7 +1,7 @@ import { toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { nowIso, shortId } from '../../api/ids'; +import { nowIso } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -57,11 +57,9 @@ const createCmd = defineCommand({ async run({ args }) { await requireAuth(); const client = await getUserClient(); - const id = shortId('pt'); const now = nowIso(); const data = await runApi( client['pack-templates'].post({ - id, name: args.name, description: args.description, category: args.category, diff --git a/packages/cli/src/commands/trips/index.ts b/packages/cli/src/commands/trips/index.ts index db75993812..8f463bef36 100644 --- a/packages/cli/src/commands/trips/index.ts +++ b/packages/cli/src/commands/trips/index.ts @@ -1,7 +1,7 @@ import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { nowIso, shortId } from '../../api/ids'; +import { nowIso } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printSummary, printTable } from '../../shared'; @@ -78,7 +78,6 @@ const createCmd = defineCommand({ async run({ args }) { await requireAuth(); const client = await getUserClient(); - const id = shortId('t'); const now = nowIso(); const lat = args.lat ? Number.parseFloat(args.lat) : null; const lon = args.lon ? Number.parseFloat(args.lon) : null; @@ -88,7 +87,6 @@ const createCmd = defineCommand({ : null; const trip = await runApi( client.trips.post({ - id, name: args.name, description: args.description, location, diff --git a/packages/mcp/src/tools/packTemplates.ts b/packages/mcp/src/tools/packTemplates.ts index a20eee085b..397e83b6e9 100644 --- a/packages/mcp/src/tools/packTemplates.ts +++ b/packages/mcp/src/tools/packTemplates.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { call, nowIso, shortId } from '../client'; +import { call, nowIso } from '../client'; import { ItemCategory, PackCategory } from '../enums'; import type { AgentContext } from '../types'; @@ -43,11 +43,9 @@ export function registerPackTemplateTools(agent: AgentContext): void { }, }, async ({ name, description, category, image, tags, is_app_template }) => { - const id = shortId('pt'); const now = nowIso(); return call( agent.api.user['pack-templates'].post({ - id, name, description, category, @@ -147,11 +145,9 @@ export function registerPackTemplateTools(agent: AgentContext): void { worn, image, notes, - }) => { - const id = shortId('pti'); - return call( + }) => + call( agent.api.user['pack-templates']({ templateId: template_id }).items.post({ - id, name, description, weight, @@ -164,8 +160,7 @@ export function registerPackTemplateTools(agent: AgentContext): void { notes, }), { action: 'add template item', resourceHint: `template ${template_id}` }, - ); - }, + ), ); agent.server.registerTool( diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index 9e9e886336..757906b371 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { call, nowIso, shortId } from '../client'; +import { call, nowIso } from '../client'; import { ItemCategory, PackCategory } from '../enums'; import type { AgentContext } from '../types'; @@ -61,11 +61,9 @@ export function registerPackTools(agent: AgentContext): void { }, }, async ({ name, description, category, is_public, tags }) => { - const id = shortId('p'); const now = nowIso(); return call( agent.api.user.packs.post({ - id, name, description, category, @@ -191,12 +189,9 @@ export function registerPackTools(agent: AgentContext): void { is_consumable, is_worn, notes, - }) => { - const id = shortId('i'); - const now = nowIso(); - return call( + }) => + call( agent.api.user.packs({ packId: pack_id }).items.post({ - id, name, category, weight: weight_grams, @@ -205,12 +200,9 @@ export function registerPackTools(agent: AgentContext): void { consumable: is_consumable, worn: is_worn, notes, - localCreatedAt: now, - localUpdatedAt: now, }), { action: 'add pack item', resourceHint: `pack ${pack_id}` }, - ); - }, + ), ); // ── Add a catalog item to a pack (lean) ────────────────────────────────── @@ -357,15 +349,13 @@ export function registerPackTools(agent: AgentContext): void { description: 'Record a weight measurement for a pack at a specific point in time.', inputSchema: { pack_id: z.string(), weight_grams: z.number().min(0) }, }, - async ({ pack_id, weight_grams }) => { - const id = shortId('w'); - return call( + async ({ pack_id, weight_grams }) => + call( agent.api.user .packs({ packId: pack_id }) - ['weight-history'].post({ id, weight: weight_grams, localCreatedAt: nowIso() }), + ['weight-history'].post({ weight: weight_grams, localCreatedAt: nowIso() }), { action: 'record pack weight', resourceHint: `pack ${pack_id}` }, - ); - }, + ), ); // ── Pack weight analysis (server-computed breakdown) ───────────────────── diff --git a/packages/mcp/src/tools/trail-conditions.ts b/packages/mcp/src/tools/trail-conditions.ts index 1c056dc90e..380abe5cdb 100644 --- a/packages/mcp/src/tools/trail-conditions.ts +++ b/packages/mcp/src/tools/trail-conditions.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { call, nowIso, shortId } from '../client'; +import { call, nowIso } from '../client'; import { CrossingDifficulty, TrailCondition, TrailSurface } from '../enums'; import type { AgentContext } from '../types'; @@ -79,11 +79,9 @@ export function registerTrailConditionTools(agent: AgentContext): void { photos, trip_id, }) => { - const id = shortId('tcr'); const now = nowIso(); return call( agent.api.user['trail-conditions'].post({ - id, trailName: trail_name, trailRegion: trail_region ?? null, surface, diff --git a/packages/mcp/src/tools/trips.ts b/packages/mcp/src/tools/trips.ts index 561f549cfb..3f78c9e2f9 100644 --- a/packages/mcp/src/tools/trips.ts +++ b/packages/mcp/src/tools/trips.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { call, nowIso, shortId } from '../client'; +import { call, nowIso } from '../client'; import type { AgentContext } from '../types'; const LocationInput = z.object({ @@ -55,11 +55,9 @@ export function registerTripTools(agent: AgentContext): void { }, }, async ({ name, description, location, start_date, end_date, notes, pack_id }) => { - const id = shortId('t'); const now = nowIso(); return call( agent.api.user.trips.post({ - id, name, description, location: location ?? null, From 31a0a6965fceac8cd784f0a25f5d6c95c06a0d1f Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 16:22:47 -0600 Subject: [PATCH 15/30] =?UTF-8?q?=F0=9F=9B=82=20api:=20POST=20/admin/login?= =?UTF-8?q?=20=E2=80=94=20body-credential=20variant=20of=20/admin/token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a JSON-body credential exchange that mirrors /admin/token's behavior but doesn't require an HTTP Basic header. The Basic-auth /token route is kept for the admin SPA's existing CF-Access-cookie + Basic flow; the new /login is for typed clients (MCP, CLI, anyone using Eden Treaty) so they no longer have to bypass Treaty just to set Authorization: Basic. - Same rate-limit + CF Access guard - Same { token, expiresIn } response shape - Same credential check, extracted to shared checkAdminCredentials() - onBeforeHandle exempts both /token and /login from the Bearer guard MCP `admin_login` and CLI `packrat admin login` now go through Treaty: no more raw `fetch` with Authorization: Basic, no more bespoke response parsers — the existing runApi/call wrappers handle errors via the typed response schema. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/admin/index.ts | 60 ++++++++++++++++++++++-- packages/cli/src/commands/admin/login.ts | 43 +++++------------ packages/mcp/src/tools/auth.ts | 29 ++++-------- 3 files changed, 76 insertions(+), 56 deletions(-) diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index e00d7d2b1b..b325a09357 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -26,6 +26,13 @@ const ADMIN_TOKEN_TTL_SECONDS = 3600; // 1 hour const ADMIN_JWT_ISSUER = 'packrat-api'; const ADMIN_JWT_AUDIENCE = 'packrat-admin'; +function checkAdminCredentials(username: string, password: string): boolean { + const env = getEnv(); + const userOk = timingSafeEqual(username, env.ADMIN_USERNAME); + const passOk = timingSafeEqual(password, env.ADMIN_PASSWORD); + return userOk && passOk; +} + function basicAuthGuard(request: Request): { authorized: true } | { authorized: false } { const header = request.headers.get('authorization') ?? ''; if (!header.startsWith('Basic ')) return { authorized: false }; @@ -36,10 +43,7 @@ function basicAuthGuard(request: Request): { authorized: true } | { authorized: if (sep === -1) return { authorized: false }; const username = decoded.slice(0, sep); const password = decoded.slice(sep + 1); - const env = getEnv(); - const userOk = timingSafeEqual(username, env.ADMIN_USERNAME); - const passOk = timingSafeEqual(password, env.ADMIN_PASSWORD); - if (userOk && passOk) return { authorized: true }; + if (checkAdminCredentials(username, password)) return { authorized: true }; } catch { return { authorized: false }; } @@ -131,6 +135,50 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) allowedHeaders: ['Authorization', 'Content-Type'], }), ) + // Login (body-credential variant) — same credential semantics as /token, + // but takes `{ username, password }` in the JSON body. Typed clients (MCP, + // CLI, Eden Treaty) can hit this without overriding the Authorization + // header. The Basic-auth /token route remains for the admin SPA. + .post( + '/login', + async ({ body, request }) => { + const env = getEnv(); + if (env.TOKEN_RATE_LIMITER) { + const ip = request.headers.get('cf-connecting-ip') ?? 'unknown'; + const { success } = await env.TOKEN_RATE_LIMITER.limit({ key: ip }); + if (!success) return status(429, { error: 'Too many requests' }); + } + const { CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD } = env; + if (CF_ACCESS_TEAM_DOMAIN && CF_ACCESS_AUD) { + const cfIdentity = await verifyCFAccessRequest(request, { + teamDomain: CF_ACCESS_TEAM_DOMAIN, + aud: CF_ACCESS_AUD, + }); + if (!cfIdentity) return status(401, { error: 'CF Access authentication required' }); + } + if (!checkAdminCredentials(body.username, body.password)) { + return status(401, { error: 'Invalid username or password' }); + } + const token = await issueAdminJwt(body.username); + return { token, expiresIn: ADMIN_TOKEN_TTL_SECONDS }; + }, + { + body: z.object({ + username: z.string().min(1), + password: z.string().min(1), + }), + response: { + 200: z.object({ token: z.string(), expiresIn: z.number() }), + 401: z.object({ error: z.string() }), + 429: z.object({ error: z.string() }), + }, + detail: { + tags: ['Admin'], + summary: 'Exchange JSON credentials for a short-lived admin JWT', + }, + }, + ) + // Token exchange — must be registered BEFORE the auth guard so the admin // SPA can exchange Basic credentials for a short-lived JWT. .post( @@ -180,7 +228,9 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) }, ) .onBeforeHandle(async ({ request, path }) => { - if (path === '/api/admin/token') return; + // Credential-exchange routes own their own auth gating (Basic for /token, + // JSON body for /login). Skip the bearer guard for both. + if (path === '/api/admin/token' || path === '/api/admin/login') return; if (request.method === 'OPTIONS') return; const ok = await adminAuthGuard(request); if (!ok) return status(401, { error: 'Unauthorized' }); diff --git a/packages/cli/src/commands/admin/login.ts b/packages/cli/src/commands/admin/login.ts index 7f21450e3b..5abe1cd2b8 100644 --- a/packages/cli/src/commands/admin/login.ts +++ b/packages/cli/src/commands/admin/login.ts @@ -1,19 +1,13 @@ -import chalk from 'chalk'; import { defineCommand } from 'citty'; import consola from 'consola'; -import { z } from 'zod'; -import { getBaseUrl } from '../../api/client'; +import { getUserClient } from '../../api/client'; import { saveConfig } from '../../api/config'; - -const TokenResponseSchema = z.object({ - token: z.string(), - expiresIn: z.number(), -}); +import { runApi } from '../../api/run'; export default defineCommand({ meta: { name: 'login', - description: 'Exchange admin Basic credentials for a short-lived admin JWT (60 min).', + description: 'Exchange admin credentials for a short-lived admin JWT (60 min).', }, args: { username: { type: 'string', alias: 'u', description: 'Admin username' }, @@ -24,30 +18,15 @@ export default defineCommand({ const password = args.password ?? (await consola.prompt('Admin password', { type: 'text', cancel: 'reject' })); - const baseUrl = await getBaseUrl(); - const basic = Buffer.from(`${username}:${password}`).toString('base64'); - const response = await fetch(`${baseUrl}/api/admin/token`, { - method: 'POST', - headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/json' }, - body: '{}', + // The user-scope Treaty client is fine here — /admin/login is the + // credential-exchange route and ignores any Bearer header. + const client = await getUserClient(); + const { token, expiresIn } = await runApi(client.admin.login.post({ username, password }), { + action: 'admin login', }); - if (!response.ok) { - const body = await response.text().catch(() => ''); - consola.error(`Admin login failed (HTTP ${response.status}).`); - if (body) consola.error(chalk.dim(body)); - process.exit(1); - } - - const parsed = TokenResponseSchema.safeParse(await response.json().catch(() => null)); - if (!parsed.success) { - consola.error('Token endpoint returned an unexpected payload.'); - process.exit(1); - } - const expiresAt = Date.now() + parsed.data.expiresIn * 1000; - await saveConfig({ adminToken: parsed.data.token, adminTokenExpiresAt: expiresAt }); - consola.success( - `Admin token stored (valid for ${Math.round(parsed.data.expiresIn / 60)} min).`, - ); + const expiresAt = Date.now() + expiresIn * 1000; + await saveConfig({ adminToken: token, adminTokenExpiresAt: expiresAt }); + consola.success(`Admin token stored (valid for ${Math.round(expiresIn / 60)} min).`); }, }); diff --git a/packages/mcp/src/tools/auth.ts b/packages/mcp/src/tools/auth.ts index ea4642a272..ab016c3118 100644 --- a/packages/mcp/src/tools/auth.ts +++ b/packages/mcp/src/tools/auth.ts @@ -11,6 +11,7 @@ * - `admin_logout` — clear the stored admin JWT. */ +import { isObject } from '@packrat/guards'; import { z } from 'zod'; import { call, errMessage, ok } from '../client'; import type { AgentContext } from '../types'; @@ -28,39 +29,29 @@ export function registerAuthTools(agent: AgentContext): void { ); // ── Admin login ─────────────────────────────────────────────────────────── - // POST /api/admin/token uses HTTP Basic auth — hit it via fetch rather than - // Treaty so we can attach the Basic header without disturbing the admin - // Treaty client's Bearer header. + // Uses the body-credential variant of /api/admin/token (POST /admin/login) + // so the call goes straight through Treaty — no Basic-header bypass. agent.server.registerTool( 'admin_login', { description: - 'Exchange admin Basic credentials (username + password) for a short-lived admin JWT and store it for the current MCP session. Required before calling any admin_* tool unless an admin JWT was already supplied via the X-PackRat-Admin-Token header.', + 'Exchange admin credentials (username + password) for a short-lived admin JWT and store it for the current MCP session. Required before calling any admin_* tool unless an admin JWT was already supplied via the X-PackRat-Admin-Token header.', inputSchema: { username: z.string().min(1), password: z.string().min(1), }, }, async ({ username, password }) => { - const basic = btoa(`${username}:${password}`); - const response = await fetch(`${agent.apiBaseUrl}/api/admin/token`, { - method: 'POST', - headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/json' }, - body: '{}', - }); - const body = (await response.json().catch(() => null)) as { - token?: string; - expiresIn?: number; - error?: string; - } | null; - if (!response.ok || !body?.token) { + const result = await agent.api.user.admin.login.post({ username, password }); + if (result.error || !result.data) { + const detail = isObject(result.error) ? (result.error.value ?? null) : null; return errMessage( - `Admin login failed (HTTP ${response.status})${body?.error ? `: ${body.error}` : ''}`, + `Admin login failed (HTTP ${result.status})${detail ? `: ${JSON.stringify(detail)}` : ''}`, ); } - agent.setAdminToken(body.token); - return ok({ ok: true, expiresIn: body.expiresIn }); + agent.setAdminToken(result.data.token); + return ok({ ok: true, expiresIn: result.data.expiresIn }); }, ); From ba03da92b84e0c6b6fd1c710d79895d652ddb1f4 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 16:34:06 -0600 Subject: [PATCH 16/30] =?UTF-8?q?=F0=9F=A9=B9=20fix=20typecheck=20regressi?= =?UTF-8?q?ons=20from=20PR=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caught by `tsc --noEmit` on the merged branch: 1. catalog_items table has `ratingValue` but not `ratingCount` — the new POST /catalog/compare route (T7) was selecting a non-existent column. Drop ratingCount from both the SQL select and CatalogCompareRowSchema. 2. T3 changed gap-analysis `duration` from string→number on the API. Mobile's `GapAnalysisRequest` interface in `apps/expo/features/packs/hooks/ usePackGapAnalysis.ts` still typed it `string` so the Treaty `.post()` call site mismatched. Align with the new API type. Both were silent at runtime in dev (Drizzle would have errored on the unknown column lazily; mobile callers happened to never pass duration). CI's typecheck would have caught them before deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/expo/features/packs/hooks/usePackGapAnalysis.ts | 2 +- packages/api/src/routes/catalog/index.ts | 1 - packages/api/src/schemas/catalog.ts | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/expo/features/packs/hooks/usePackGapAnalysis.ts b/apps/expo/features/packs/hooks/usePackGapAnalysis.ts index 3387d24278..dc1d7db5c7 100644 --- a/apps/expo/features/packs/hooks/usePackGapAnalysis.ts +++ b/apps/expo/features/packs/hooks/usePackGapAnalysis.ts @@ -4,7 +4,7 @@ import { apiClient } from 'expo-app/lib/api/packrat'; export interface GapAnalysisRequest { destination?: string; tripType?: string; - duration?: string; + duration?: number; startDate?: string; endDate?: string; } diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index 0ad19d3166..8a7d05d932 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -149,7 +149,6 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) weightUnit: catalogItems.weightUnit, price: catalogItems.price, ratingValue: catalogItems.ratingValue, - ratingCount: catalogItems.ratingCount, productUrl: catalogItems.productUrl, categories: catalogItems.categories, }) diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index 9aa119d8a5..af6729b2e4 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -358,7 +358,6 @@ export const CatalogCompareRowSchema = z.object({ weightUnit: z.string().nullable(), price: z.number().nullable(), ratingValue: z.number().nullable(), - ratingCount: z.number().nullable(), productUrl: z.string().nullable(), categories: z.array(z.string()).nullable(), }); From dda500c27e4252da29ad8afe0271b0c7f26c2afa Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 16:36:54 -0600 Subject: [PATCH 17/30] =?UTF-8?q?=F0=9F=A9=B9=20fix=20admin=20SPA=20callsi?= =?UTF-8?q?tes=20broken=20by=20includeDeleted=20boolean=20coercion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2 changed admin packs-list / trails/conditions `includeDeleted` from a string ('true'/undefined) to a boolean, and dropped includeDeleted entirely from admin users-list (dead code — Better Auth doesn't support user soft- delete). Three callers in apps/admin/lib/api.ts still passed the string form and one still set it on users-list — caught by CI typecheck on #2433. - getUsers(): drop includeDeleted from the request; keep the param on the public signature for compat but stop forwarding it. - getPacks(): pass the boolean through unchanged. - getTrailConditions(): same. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin/lib/api.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index 1df1af5636..7b4d222b4c 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -94,8 +94,12 @@ export async function getUsers({ q?: string; includeDeleted?: boolean; } = {}): Promise> { + // users-list no longer accepts includeDeleted — Better Auth doesn't support + // user soft-delete, so the field was dead code. Caller-supplied value is + // ignored. + void includeDeleted; const { data, error } = await adminClient['users-list'].get({ - query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined }, + query: { limit, offset, q }, }); if (error) throwOnError(error); return unwrap(data, 'users'); @@ -138,7 +142,7 @@ export async function getPacks({ includeDeleted?: boolean; } = {}): Promise> { const { data, error } = await adminClient['packs-list'].get({ - query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined }, + query: { limit, offset, q, includeDeleted }, }); if (error) throwOnError(error); return unwrap(data, 'packs'); @@ -323,7 +327,7 @@ export async function getTrailConditions({ includeDeleted?: boolean; } = {}): Promise> { const { data, error } = await adminClient.trails.conditions.get({ - query: { q, limit, offset, includeDeleted: includeDeleted ? 'true' : undefined }, + query: { q, limit, offset, includeDeleted }, }); if (error) throwOnError(error); return unwrap(data, 'trailConditions'); From 61536f6bf719ac16fc6efeee7ac18ad25d2a3ff4 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 16:59:25 -0600 Subject: [PATCH 18/30] =?UTF-8?q?=F0=9F=94=92=20fix(api):=20security/corre?= =?UTF-8?q?ctness=20issues=20from=20PR=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from Copilot's review on PR #2433: 1. weight-breakdown ACL — GET /packs/:packId/weight-breakdown returned the item-level breakdown without ownership/isPublic check. Any authenticated user could enumerate other packs' item names + categories. Match the owner-or-public pattern used by GET /:packId/items. 2. queryBoolean() replaces z.coerce.boolean() — coerce treats any non-empty string as truthy, so ?includeDeleted=false silently parsed as true and bypassed the soft-delete filter on admin packs-list and admin trail conditions. New shared helper in @packrat/guards strictly maps 'true'/'1' → true and 'false'/'0'/'' → false; anything else fails validation. 3. catalog/compare missing-id validation — endpoint returned a successful comparison even when supplied IDs didn't exist (and could compare a single item despite the 2+ schema floor). Now returns 404 listing the missing IDs. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/admin/index.ts | 7 +++++-- packages/api/src/routes/admin/trails.ts | 4 +++- packages/api/src/routes/catalog/index.ts | 11 ++++++++--- packages/api/src/routes/packs/index.ts | 7 +++++-- packages/guards/src/parse.ts | 23 ++++++++++++++++++++++- 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index b325a09357..0c90722d26 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -14,7 +14,7 @@ import { } from '@packrat/api/schemas/admin'; import { timingSafeEqual } from '@packrat/api/utils/auth'; import { getEnv } from '@packrat/api/utils/env-validation'; -import { assertAllDefined } from '@packrat/guards'; +import { assertAllDefined, queryBoolean } from '@packrat/guards'; import { and, count, desc, eq, ilike, or } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { jwtVerify, SignJWT } from 'jose'; @@ -401,7 +401,10 @@ export const adminRoutes = new Elysia({ prefix: '/admin' }) limit: z.coerce.number().int().positive().max(100).optional(), offset: z.coerce.number().int().min(0).optional(), q: z.string().optional(), - includeDeleted: z.coerce.boolean().optional(), + // queryBoolean() instead of z.coerce.boolean() — the latter treats + // any non-empty string as truthy, so ?includeDeleted=false would + // wrongly include soft-deleted rows. + includeDeleted: queryBoolean(), }), response: { 200: AdminPacksListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List packs' }, diff --git a/packages/api/src/routes/admin/trails.ts b/packages/api/src/routes/admin/trails.ts index 3c777455d0..4a8b9e629f 100644 --- a/packages/api/src/routes/admin/trails.ts +++ b/packages/api/src/routes/admin/trails.ts @@ -8,6 +8,7 @@ import { TrailSearchItemSchema, TrailSearchResultSchema, } from '@packrat/api/schemas/admin'; +import { queryBoolean } from '@packrat/guards'; import { and, count, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { z } from 'zod'; @@ -312,7 +313,8 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) q: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).optional(), offset: z.coerce.number().int().min(0).optional(), - includeDeleted: z.coerce.boolean().optional(), + // queryBoolean() — see admin/index.ts for why we avoid z.coerce.boolean + includeDeleted: queryBoolean(), }), response: { 200: TrailConditionsListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List all trail condition reports' }, diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index 8a7d05d932..fd5637ec99 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -140,6 +140,7 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) async ({ body }) => { const db = createDb(); const { ids } = body; + const uniqueIds = Array.from(new Set(ids)); const items = await db .select({ id: catalogItems.id, @@ -153,10 +154,14 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) categories: catalogItems.categories, }) .from(catalogItems) - .where(inArray(catalogItems.id, ids)); + .where(inArray(catalogItems.id, uniqueIds)); - if (items.length === 0) { - return status(404, { error: 'No catalog items matched the supplied IDs' }); + const foundIds = new Set(items.map((it) => it.id)); + const missing = uniqueIds.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + return status(404, { + error: `Catalog item(s) not found: ${missing.join(', ')}`, + }); } const rank = ( diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 2c31b64958..5bbc94a2d8 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -268,10 +268,11 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) // Weight breakdown — total/base/worn/consumable + per-category aggregation. // Edge apps were computing this by walking pack.items locally; centralising - // here keeps MCP/CLI tools as one-line passthroughs. + // here keeps MCP/CLI tools as one-line passthroughs. Matches the + // owner-or-public access pattern used by GET /:packId/items. .get( '/:packId/weight-breakdown', - async ({ params }) => { + async ({ params, user }) => { const db = createDb(); try { const pack = await db.query.packs.findFirst({ @@ -279,6 +280,8 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) with: { items: { where: eq(packItems.deleted, false) } }, }); if (!pack) return status(404, { error: 'Pack not found' }); + const canAccess = pack.isPublic || pack.userId === user.userId; + if (!canAccess) return status(403, { error: 'Unauthorized' }); return computePackBreakdown(pack); } catch (error) { console.error('Error computing pack breakdown:', error); diff --git a/packages/guards/src/parse.ts b/packages/guards/src/parse.ts index 5a98413eeb..c0c23ae455 100644 --- a/packages/guards/src/parse.ts +++ b/packages/guards/src/parse.ts @@ -5,7 +5,7 @@ * don't need to unpack `{ success, data }` everywhere, and so the * pattern is consistent across the codebase. */ -import type { ZodSchema } from 'zod'; +import { type ZodSchema, z } from 'zod'; /** * Returns a parser function `(value: unknown) => T | undefined`. @@ -37,3 +37,24 @@ export const zodGuard = (schema: ZodSchema) => (value: unknown): value is T => schema.safeParse(value).success; + +/** + * Strict boolean parser for HTTP query strings — `'true' / '1'` → true, + * `'false' / '0' / ''` → false, anything else fails validation. + * + * Why a custom parser: `z.coerce.boolean()` treats every non-empty string as + * truthy, so `?includeDeleted=false` arrives at the handler as `true`. That + * silently bypasses ACL filters that gate on "include soft-deleted" flags. + * + * @example + * query: z.object({ includeDeleted: queryBoolean() }) + */ +export const queryBoolean = () => + z + .preprocess((v) => { + if (typeof v === 'boolean') return v; + if (v === 'true' || v === '1') return true; + if (v === 'false' || v === '0' || v === '' || v === undefined || v === null) return false; + return v; + }, z.boolean()) + .optional(); From e42a053c7f6b34f8de0c8af7f4cf3c955a74cd49 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 17:01:19 -0600 Subject: [PATCH 19/30] =?UTF-8?q?=F0=9F=A9=B9=20fix(cli):=20unwrap=20pagin?= =?UTF-8?q?ated/nested=20response=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six commands were reading the top-level response as if it were the data: - auth/whoami, user/profile → response is { success, user: {…} }, not flat. Fields rendered as dashes for fully populated profiles. - admin/users, admin/packs, admin/catalog, admin/trails → admin lists are paginated `{ data, total, limit, offset }`; tables came up empty. Unwrap the nested key before mapping. The toRecord/toRecordArray helpers in @packrat/guards already short-circuit non-object inputs to {} / [], so misconfigured responses still render safely. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/admin/catalog.ts | 5 +++-- packages/cli/src/commands/admin/packs.ts | 3 ++- packages/cli/src/commands/admin/trails.ts | 3 ++- packages/cli/src/commands/admin/users.ts | 3 ++- packages/cli/src/commands/auth/whoami.ts | 9 +++++---- packages/cli/src/commands/user/index.ts | 11 ++++++----- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/admin/catalog.ts b/packages/cli/src/commands/admin/catalog.ts index ae743b8a53..5e45be1ccf 100644 --- a/packages/cli/src/commands/admin/catalog.ts +++ b/packages/cli/src/commands/admin/catalog.ts @@ -1,4 +1,4 @@ -import { isString, toRecordArray } from '@packrat/guards'; +import { isString, toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; @@ -30,8 +30,9 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } + // Endpoint returns { data: [...], total, limit, offset } printTable( - toRecordArray(data).map((it) => ({ + toRecordArray(toRecord(data).data).map((it) => ({ id: it.id, name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, diff --git a/packages/cli/src/commands/admin/packs.ts b/packages/cli/src/commands/admin/packs.ts index 7e962d9af4..fdd1242fc5 100644 --- a/packages/cli/src/commands/admin/packs.ts +++ b/packages/cli/src/commands/admin/packs.ts @@ -32,8 +32,9 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } + // Endpoint returns { data: [...], total, limit, offset } printTable( - toRecordArray(data).map((p) => ({ + toRecordArray(toRecord(data).data).map((p) => ({ id: p.id, name: p.name, userId: p.userId, diff --git a/packages/cli/src/commands/admin/trails.ts b/packages/cli/src/commands/admin/trails.ts index 8389ee5e9f..a4c5ef02e8 100644 --- a/packages/cli/src/commands/admin/trails.ts +++ b/packages/cli/src/commands/admin/trails.ts @@ -65,8 +65,9 @@ const reportsCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } + // Endpoint returns { data: [...], total, limit, offset } printTable( - toRecordArray(data).map((r) => ({ + toRecordArray(toRecord(data).data).map((r) => ({ id: r.id, trailName: r.trailName, condition: r.overallCondition, diff --git a/packages/cli/src/commands/admin/users.ts b/packages/cli/src/commands/admin/users.ts index 1d3eeb9cf6..0f443c6e99 100644 --- a/packages/cli/src/commands/admin/users.ts +++ b/packages/cli/src/commands/admin/users.ts @@ -30,7 +30,8 @@ const listCmd = defineCommand({ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); return; } - const items = Array.isArray(data) ? toRecordArray(data) : toRecordArray(toRecord(data).items); + // Endpoint returns { data: [...], total, limit, offset } + const items = toRecordArray(toRecord(data).data); printTable( items.map((u) => ({ id: u.id, diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 32abc76e12..679228b344 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -11,15 +11,16 @@ export default defineCommand({ async run() { await requireAuth(); const client = await getUserClient(); - const profile = toRecord(await runApi(client.user.profile.get(), { action: 'fetch profile' })); + const response = toRecord(await runApi(client.user.profile.get(), { action: 'fetch profile' })); + const user = toRecord(response.user); const config = await loadConfig(); printSummary( { baseUrl: config.baseUrl, userId: config.userId ?? '—', - email: config.userEmail ?? '—', - firstName: profile.firstName ?? '—', - lastName: profile.lastName ?? '—', + email: config.userEmail ?? user.email ?? '—', + firstName: user.firstName ?? '—', + lastName: user.lastName ?? '—', adminTokenSet: Boolean(config.adminToken), configFile: CONFIG_FILE_PATH, }, diff --git a/packages/cli/src/commands/user/index.ts b/packages/cli/src/commands/user/index.ts index b52c16f555..c116f5e87c 100644 --- a/packages/cli/src/commands/user/index.ts +++ b/packages/cli/src/commands/user/index.ts @@ -10,13 +10,14 @@ const getCmd = defineCommand({ await requireAuth(); const client = await getUserClient(); const data = await runApi(client.user.profile.get(), { action: 'get profile' }); - const r = toRecord(data); + // Endpoint returns { success, user: { firstName, ... } } + const user = toRecord(toRecord(data).user); printSummary( { - firstName: r.firstName, - lastName: r.lastName, - email: r.email, - avatarUrl: r.avatarUrl, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + avatarUrl: user.avatarUrl, }, 'Profile', ); From ec8ea7f47526925e9f476a696dd15d33310400e8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 17:04:41 -0600 Subject: [PATCH 20/30] =?UTF-8?q?=F0=9F=9A=B8=20polish=20PR=20review=20fee?= =?UTF-8?q?dback=20=E2=80=94=20passwords,=20config,=20network=20errors,=20?= =?UTF-8?q?MCP=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four UX/correctness items from Copilot's review: - promptPassword() helper using raw-mode stdin; the three CLI login commands (auth login, auth register, admin login) now mask credentials instead of echoing them via consola.prompt({ type: 'text' }). Falls back to a plain readline.question for non-TTY (CI/piped). - config: PACKRAT_API_URL env override no longer leaks into the persisted ~/.packrat/config.json. Split into loadPersisted() (disk state) and loadConfig() (effective = persisted + env). saveConfig() merges into the persisted state and returns the effective view, so a sign-in while the override is set doesn't permanently rewrite baseUrl. - runApi/tryApi: catch the await — fetch throws on DNS / refused / TLS / unreachable, and runApi was letting the exception bubble as a stack trace instead of the friendly process.exit(1) it promises. tryApi surfaces network failures as { status: 0, value: { networkError } } so callers can branch on them. - MCP `generate_pack_template_from_url` description used to say "use admin_login first", but the underlying endpoint gates on `user.role === 'ADMIN'` on the OAuth user — not the admin JWT. Reword to make the actual requirement explicit and drop the misleading requiresAdmin hint. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/api/config.ts | 41 +++++++++----- packages/cli/src/api/prompt.ts | 64 ++++++++++++++++++++++ packages/cli/src/api/run.ts | 26 ++++++++- packages/cli/src/commands/admin/login.ts | 4 +- packages/cli/src/commands/auth/login.ts | 4 +- packages/cli/src/commands/auth/register.ts | 4 +- packages/mcp/src/tools/packTemplates.ts | 4 +- 7 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/api/prompt.ts diff --git a/packages/cli/src/api/config.ts b/packages/cli/src/api/config.ts index 35448a0179..d9de3e3bf1 100644 --- a/packages/cli/src/api/config.ts +++ b/packages/cli/src/api/config.ts @@ -43,38 +43,51 @@ const emptyConfig: CliConfig = { userId: null, }; -let cached: CliConfig | null = null; +// Persisted state — exactly what's in (or will be in) ~/.packrat/config.json. +let persisted: CliConfig | null = null; -/** Read the config from disk (cached for the lifetime of the process). */ -export async function loadConfig(): Promise { - if (cached) return cached; +/** Load the persisted config from disk (cached for the lifetime of the process). */ +async function loadPersisted(): Promise { + if (persisted) return persisted; try { const raw = await readFile(CONFIG_PATH, 'utf8'); const parsed = CliConfigSchema.safeParse(JSON.parse(raw)); - cached = parsed.success ? parsed.data : emptyConfig; + persisted = parsed.success ? parsed.data : emptyConfig; } catch (e) { - if (isNotFound(e)) cached = { ...emptyConfig }; + if (isNotFound(e)) persisted = { ...emptyConfig }; else throw e; } - // PACKRAT_API_URL env override always wins. Useful for local dev (e.g. - // pointing the CLI at `http://localhost:8787`). + return persisted; +} + +/** + * Return the effective runtime config — the persisted state with the + * `PACKRAT_API_URL` env override layered on top. Callers that need to know + * the persisted baseUrl (e.g. a future `packrat config show`) should use + * `loadPersisted` directly; almost everyone wants the effective value. + */ +export async function loadConfig(): Promise { + const base = await loadPersisted(); const envOverride = nodeEnv.PACKRAT_API_URL?.trim(); - if (envOverride) cached.baseUrl = envOverride; - return cached; + // Return a copy so accidental mutation can't leak back into the persisted + // cache and end up written to disk via saveConfig(). + if (envOverride) return { ...base, baseUrl: envOverride }; + return { ...base }; } /** Merge a partial update into the config and persist atomically. */ export async function saveConfig(patch: Partial): Promise { - const current = await loadConfig(); + // Merge into the *persisted* state, not the effective config — otherwise a + // PACKRAT_API_URL env override would get written to disk and stick. + const current = await loadPersisted(); const next: CliConfig = { ...current, ...patch }; await mkdir(dirname(CONFIG_PATH), { recursive: true }); - // Write to a tmp file then rename so partial writes can't corrupt config. const tmp = `${CONFIG_PATH}.tmp`; await writeFile(tmp, JSON.stringify(next, null, 2), { mode: 0o600 }); const { rename } = await import('node:fs/promises'); await rename(tmp, CONFIG_PATH); - cached = next; - return next; + persisted = next; + return loadConfig(); } /** Clear all session-level tokens but keep `baseUrl`. */ diff --git a/packages/cli/src/api/prompt.ts b/packages/cli/src/api/prompt.ts new file mode 100644 index 0000000000..2b97e75296 --- /dev/null +++ b/packages/cli/src/api/prompt.ts @@ -0,0 +1,64 @@ +/** + * Password prompt helper. + * + * `consola.prompt({ type: 'text' })` echoes the typed value to the terminal, + * which leaks credentials over the user's scrollback / clipboard. Consola + * doesn't expose a password type, so this wraps `node:readline` with raw + * stdin mode to mask the input. + * + * Falls back to a plain prompt when stdin isn't a TTY (CI, piped input). + */ + +import { stdin, stdout } from 'node:process'; +import { createInterface } from 'node:readline'; + +export async function promptPassword(label: string): Promise { + // Non-TTY (CI, piped) — read a line as-is. + if (!stdin.isTTY || !stdin.setRawMode) { + const rl = createInterface({ input: stdin, output: stdout }); + try { + return await new Promise((resolve) => { + rl.question(`${label}: `, (answer) => resolve(answer)); + }); + } finally { + rl.close(); + } + } + + stdout.write(`${label}: `); + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + return new Promise((resolve) => { + let password = ''; + const onData = (chunk: string): void => { + for (const ch of chunk) { + const code = ch.charCodeAt(0); + if (ch === '\r' || ch === '\n' || code === 4) { + // Enter or Ctrl-D — submit + stdin.setRawMode(false); + stdin.pause(); + stdin.off('data', onData); + stdout.write('\n'); + resolve(password); + return; + } + if (code === 3) { + // Ctrl-C — abort + stdin.setRawMode(false); + stdin.pause(); + stdout.write('\n'); + process.exit(130); + } + if (ch === '' || ch === '\b') { + // Backspace + if (password.length > 0) password = password.slice(0, -1); + } else if (code >= 32) { + password += ch; + } + } + }; + stdin.on('data', onData); + }); +} diff --git a/packages/cli/src/api/run.ts b/packages/cli/src/api/run.ts index 7f6bdfb475..06eae5f9e9 100644 --- a/packages/cli/src/api/run.ts +++ b/packages/cli/src/api/run.ts @@ -41,7 +41,16 @@ export async function runApi( promise: Promise, opts: RunOptions, ): Promise> { - const result = await promise; + let result: R; + try { + result = await promise; + } catch (e) { + // fetch throws on DNS / refused / TLS / API down — surface a friendly + // message instead of an unhandled stack trace. + const message = e instanceof Error ? e.message : String(e); + consola.error(`${opts.action} failed: could not reach the PackRat API. ${chalk.dim(message)}`); + process.exit(1); + } if (result.error || result.data == null) { printError({ status: result.status, body: errorValue(result.error), opts }); process.exit(1); @@ -56,9 +65,20 @@ export async function runApi( export async function tryApi( promise: Promise, ): Promise< - { ok: true; data: NonNullable } | { ok: false; status: number; value: unknown } + | { ok: true; data: NonNullable } + | { ok: false; status: number; value: unknown } + | { ok: false; status: 0; value: { networkError: string } } > { - const result = await promise; + let result: R; + try { + result = await promise; + } catch (e) { + return { + ok: false, + status: 0, + value: { networkError: e instanceof Error ? e.message : String(e) }, + }; + } if (result.error || result.data == null) { return { ok: false, status: result.status, value: errorValue(result.error) }; } diff --git a/packages/cli/src/commands/admin/login.ts b/packages/cli/src/commands/admin/login.ts index 5abe1cd2b8..b26c1af057 100644 --- a/packages/cli/src/commands/admin/login.ts +++ b/packages/cli/src/commands/admin/login.ts @@ -2,6 +2,7 @@ import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; import { saveConfig } from '../../api/config'; +import { promptPassword } from '../../api/prompt'; import { runApi } from '../../api/run'; export default defineCommand({ @@ -15,8 +16,7 @@ export default defineCommand({ }, async run({ args }) { const username = args.username ?? (await consola.prompt('Admin username', { type: 'text' })); - const password = - args.password ?? (await consola.prompt('Admin password', { type: 'text', cancel: 'reject' })); + const password = args.password ?? (await promptPassword('Admin password')); // The user-scope Treaty client is fine here — /admin/login is the // credential-exchange route and ignores any Bearer header. diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 7f9880763b..505e9fe793 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -4,6 +4,7 @@ import consola from 'consola'; import { z } from 'zod'; import { getBaseUrl } from '../../api/client'; import { saveConfig } from '../../api/config'; +import { promptPassword } from '../../api/prompt'; const SignInResponseSchema = z.object({ session: z.object({ token: z.string() }).optional(), @@ -23,8 +24,7 @@ export default defineCommand({ }, async run({ args }) { const email = args.email ?? (await consola.prompt('Email', { type: 'text' })); - const password = - args.password ?? (await consola.prompt('Password', { type: 'text', cancel: 'reject' })); + const password = args.password ?? (await promptPassword('Password')); if (!email || !password) { consola.error('Email and password are required.'); diff --git a/packages/cli/src/commands/auth/register.ts b/packages/cli/src/commands/auth/register.ts index 839b2dc66b..55cb05d6cc 100644 --- a/packages/cli/src/commands/auth/register.ts +++ b/packages/cli/src/commands/auth/register.ts @@ -4,6 +4,7 @@ import consola from 'consola'; import { z } from 'zod'; import { getBaseUrl } from '../../api/client'; import { saveConfig } from '../../api/config'; +import { promptPassword } from '../../api/prompt'; const SignUpResponseSchema = z.object({ session: z.object({ token: z.string() }).optional(), @@ -24,8 +25,7 @@ export default defineCommand({ async run({ args }) { const email = args.email ?? (await consola.prompt('Email', { type: 'text' })); const name = args.name ?? (await consola.prompt('Name', { type: 'text' })); - const password = - args.password ?? (await consola.prompt('Password', { type: 'text', cancel: 'reject' })); + const password = args.password ?? (await promptPassword('Password')); const baseUrl = await getBaseUrl(); const response = await fetch(`${baseUrl}/api/auth/sign-up/email`, { diff --git a/packages/mcp/src/tools/packTemplates.ts b/packages/mcp/src/tools/packTemplates.ts index 397e83b6e9..7dd7a284ce 100644 --- a/packages/mcp/src/tools/packTemplates.ts +++ b/packages/mcp/src/tools/packTemplates.ts @@ -215,7 +215,7 @@ export function registerPackTemplateTools(agent: AgentContext): void { 'generate_pack_template_from_url', { description: - 'Generate a pack template from a TikTok or YouTube link (admin-only). Use admin_login first.', + 'Generate a pack template from a TikTok or YouTube link. The server gates this on `user.role === "ADMIN"` on the OAuth-authenticated user — the admin_login JWT does NOT authorize this call. Your signed-in PackRat account must be an admin.', inputSchema: { content_url: z.string().url(), is_app_template: z.boolean().default(false), @@ -227,7 +227,7 @@ export function registerPackTemplateTools(agent: AgentContext): void { contentUrl: content_url, isAppTemplate: is_app_template, }), - { action: 'generate pack template from URL', requiresAdmin: true }, + { action: 'generate pack template from URL' }, ), ); } From 279b2179891ce07dbef687a07da5048290c08cc7 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 17:06:34 -0600 Subject: [PATCH 21/30] =?UTF-8?q?=F0=9F=A9=B9=20cli:=20import=20toRecord?= =?UTF-8?q?=20in=20admin/packs.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed when I switched admin/packs.ts to unwrap the paginated response in fe9bb1f — the new toRecord(data).data call had no matching import. CI typecheck caught it. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/commands/admin/packs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/admin/packs.ts b/packages/cli/src/commands/admin/packs.ts index fdd1242fc5..fe65d55ea0 100644 --- a/packages/cli/src/commands/admin/packs.ts +++ b/packages/cli/src/commands/admin/packs.ts @@ -1,4 +1,4 @@ -import { toRecordArray } from '@packrat/guards'; +import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getAdminClient } from '../../api/client'; From 2800282911a3f7e33ff807e7e532eb15d59a6594 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 18:42:15 -0600 Subject: [PATCH 22/30] =?UTF-8?q?=F0=9F=A9=B9=20address=20CodeRabbit=20rev?= =?UTF-8?q?iew=20on=20PR=20#2433=20=E2=80=94=20fixes=20for=20real=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worked through the 35 inline findings. Skipped the pure refactor suggestions (move logic into services) and the cosmetic nits; this commit covers the real bugs and validation-tightening that change behavior. Server (api): - catalog/compare: dedupe-then-floor — [1,1] passed `.min(2)` but collapsed to one unique row; now rejected with a clear 400. - packs/items/from-catalog: catalog row may have `name === null`, which would surface as Postgres 23502. Validate up front and return 422 with the catalog ID. - weather/by-name: remove unreachable `q.length < 2` check (schema already enforces it). - compute-pack `itemCount`: top-level was row count, byCategory was quantity-aware. Make top-level quantity-aware so a pack with `qty:3` of a single row reports 3. - trips/CreateTripRequestSchema.id, trail-conditions report `id`: `z.string().optional()` accepted ''/whitespace; tighten to `.trim().min(1).optional()` so bad values fail validation instead of slipping past `?? mintId(…)`. CLI: - catalog semantic: was rendering `it.score`; the API field is `similarity`. - catalog get: was rendering `r.ratingCount`; catalog has `reviewCount` (a distinct column). - packs gap-analysis: validate `--duration` parses to a positive integer before posting (otherwise NaN → API 422). MCP: - admin packs/trails list: was passing `includeDeleted: 1 | 0`; the API now uses the strict `queryBoolean()` parser so the typed-client contract is boolean. - index.ts: when an admin JWT arrives via the `X-PackRat-Admin-Token` header (rather than the `admin_login` tool), `syncAdminToolVisibility` wasn't called, so admin tools stayed hidden. Mirror the setAdminToken flow here. - update_pack_template_item input schema was missing `image`, so image changes were impossible post-create. Add it. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/catalog/index.ts | 6 ++++++ packages/api/src/routes/packs/index.ts | 8 ++++++++ packages/api/src/routes/trailConditions/reports.ts | 7 ++++++- packages/api/src/routes/trips/index.ts | 5 +++-- packages/api/src/routes/weather.ts | 5 ++--- packages/api/src/utils/compute-pack.ts | 5 ++++- packages/cli/src/commands/catalog/index.ts | 4 ++-- packages/cli/src/commands/packs/gap-analysis.ts | 8 +++++++- packages/mcp/src/index.ts | 5 +++++ packages/mcp/src/tools/admin.ts | 4 ++-- packages/mcp/src/tools/packTemplates.ts | 1 + 11 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index b497c683d1..75b20a0495 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -141,6 +141,12 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) const db = createDb(); const { ids } = body; const uniqueIds = Array.from(new Set(ids)); + // `ids.min(2)` accepts [1, 1] which collapses to 1 unique ID after + // dedupe; enforce the 2+ floor on the deduped set so the response + // actually contains a comparison. + if (uniqueIds.length < 2) { + return status(400, { error: 'Compare requires at least 2 distinct catalog IDs' }); + } const items = await db .select({ id: catalogItems.id, diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 5bbc94a2d8..d3895281e3 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -737,6 +737,14 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s if (!catalog) { return status(404, { error: `Catalog item ${body.catalogItemId} not found` }); } + // `name` is NOT NULL in pack_items; a catalog row missing it would + // produce a Postgres `23502 not_null_violation` deep in the insert. + // Fail fast with a 422 so the caller sees the misconfigured row. + if (!catalog.name || catalog.name.trim().length === 0) { + return status(422, { + error: `Catalog item ${body.catalogItemId} has no name and cannot be added to a pack`, + }); + } const id = mintId('i'); const now = new Date(); diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index 5ac7f5bd6a..2edc61b923 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -15,7 +15,12 @@ const LIKE_ESCAPE_UNDERSCORE = /_/g; const CreateReportRequestSchema = z.object({ // id optional — server mints if absent (lean callers). Offline-first // stores keep supplying client-side IDs for sync. - id: z.string().optional().describe('Client-generated report ID; server mints when absent'), + id: z + .string() + .trim() + .min(1) + .optional() + .describe('Client-generated report ID; server mints when absent'), trailName: z.string().min(1), trailRegion: z.string().optional().nullable(), surface: z.enum(['paved', 'gravel', 'dirt', 'rocky', 'snow', 'mud']), diff --git a/packages/api/src/routes/trips/index.ts b/packages/api/src/routes/trips/index.ts index 72eeeb9b81..8ab09a50c1 100644 --- a/packages/api/src/routes/trips/index.ts +++ b/packages/api/src/routes/trips/index.ts @@ -17,8 +17,9 @@ const LocationSchema = z const CreateTripRequestSchema = z.object({ // id optional so server can mint for lean callers; offline-first stores - // (mobile) keep supplying client-side IDs for sync. - id: z.string().optional(), + // (mobile) keep supplying client-side IDs for sync. min(1) rejects '' + // and whitespace that would otherwise slip past `?? mintId(…)`. + id: z.string().trim().min(1).optional(), name: z.string().min(1), description: z.string().optional().nullable(), location: LocationSchema, diff --git a/packages/api/src/routes/weather.ts b/packages/api/src/routes/weather.ts index 264b0ac44a..7e040c3474 100644 --- a/packages/api/src/routes/weather.ts +++ b/packages/api/src/routes/weather.ts @@ -179,10 +179,9 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) '/by-name', async ({ query }) => { const { WEATHER_API_KEY } = getEnv(); + // Schema enforces z.string().min(2); Elysia rejects shorter values + // before the handler runs. const q = query.q; - if (!q || q.length < 2) { - return status(400, { error: 'Query parameter q (≥ 2 chars) is required' }); - } try { const searchResponse = await fetch( `${WEATHER_API_BASE_URL}/search.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, diff --git a/packages/api/src/utils/compute-pack.ts b/packages/api/src/utils/compute-pack.ts index ae808d3a3f..b2dc52d1fe 100644 --- a/packages/api/src/utils/compute-pack.ts +++ b/packages/api/src/utils/compute-pack.ts @@ -96,13 +96,16 @@ export const computePackBreakdown = (pack: PackWithItems): PackWeightBreakdown = entry.totalLbs = Math.round((entry.totalGrams / GRAMS_PER_LB) * 100) / 100; } + // Quantity-aware so the top-level total matches the sum of byCategory + // itemCount values (a pack with `qty: 3` of a single row counts as 3). + const itemCount = pack.items.reduce((sum, item) => sum + item.quantity, 0); return { packId: pack.id, totalGrams: Math.round(totalGrams), baseGrams: Math.round(baseGrams), wornGrams: Math.round(wornGrams), consumableGrams: Math.round(consumableGrams), - itemCount: pack.items.length, + itemCount, byCategory: Object.values(byCategory).sort((a, b) => b.totalGrams - a.totalGrams), }; }; diff --git a/packages/cli/src/commands/catalog/index.ts b/packages/cli/src/commands/catalog/index.ts index ca468db741..5ed577d344 100644 --- a/packages/cli/src/commands/catalog/index.ts +++ b/packages/cli/src/commands/catalog/index.ts @@ -66,7 +66,7 @@ const semanticCmd = defineCommand({ id: it.id, name: isString(it.name) ? it.name.slice(0, 60) : it.name, brand: it.brand, - score: it.score, + similarity: it.similarity, })), { title: `Semantic: "${args.q}"` }, ); @@ -99,7 +99,7 @@ const getCmd = defineCommand({ weight: r.weight, price: r.price, rating: r.ratingValue, - reviewCount: r.ratingCount, + reviewCount: r.reviewCount, productUrl: r.productUrl, }, `Item ${r.id}`, diff --git a/packages/cli/src/commands/packs/gap-analysis.ts b/packages/cli/src/commands/packs/gap-analysis.ts index d4916eeada..a3a1e523b7 100644 --- a/packages/cli/src/commands/packs/gap-analysis.ts +++ b/packages/cli/src/commands/packs/gap-analysis.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import consola from 'consola'; import { getUserClient } from '../../api/client'; import { requireAuth, runApi } from '../../api/run'; @@ -25,12 +26,17 @@ export default defineCommand({ }, async run({ args }) { await requireAuth(); + const duration = Number.parseInt(args.duration, 10); + if (!Number.isInteger(duration) || duration < 1) { + consola.error(`Invalid --duration "${args.duration}" — must be a positive integer (days).`); + process.exit(1); + } const client = await getUserClient(); const result = await runApi( client.packs({ packId: args.id })['gap-analysis'].post({ destination: args.destination, tripType: args['trip-type'], - duration: Number.parseInt(args.duration, 10), + duration, startDate: args.start, endDate: args.end, }), diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index f56487eb5f..443a362bab 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -168,7 +168,12 @@ export class PackRatMCP extends McpAgent> { const nextAuth = userToken || this.state.authToken; const nextAdmin = adminToken || this.state.adminToken; if (nextAuth !== this.state.authToken || nextAdmin !== this.state.adminToken) { + const adminChanged = nextAdmin !== this.state.adminToken; this.setState({ ...this.state, authToken: nextAuth, adminToken: nextAdmin }); + // Mirror setAdminToken: when the header path swaps the admin JWT, the + // tools/list visibility must follow. Without this the model can't see + // admin tools even after a valid header was supplied. + if (adminChanged) this.syncAdminToolVisibility(); } return super.fetch(request); diff --git a/packages/mcp/src/tools/admin.ts b/packages/mcp/src/tools/admin.ts index 718a5f3aea..98b4ec8e3f 100644 --- a/packages/mcp/src/tools/admin.ts +++ b/packages/mcp/src/tools/admin.ts @@ -71,7 +71,7 @@ export function registerAdminTools(agent: AgentContext): void { async ({ q, limit, offset, include_deleted }) => call( agent.api.admin.admin['packs-list'].get({ - query: { q, limit, offset, includeDeleted: include_deleted ? 1 : 0 }, + query: { q, limit, offset, includeDeleted: include_deleted }, }), { action: 'list packs (admin)', ...ADMIN }, ), @@ -216,7 +216,7 @@ export function registerAdminTools(agent: AgentContext): void { async ({ q, limit, offset, include_deleted }) => call( agent.api.admin.admin.trails.conditions.get({ - query: { q, limit, offset, includeDeleted: include_deleted ? 1 : 0 }, + query: { q, limit, offset, includeDeleted: include_deleted }, }), { action: 'list trail condition reports (admin)', ...ADMIN }, ), diff --git a/packages/mcp/src/tools/packTemplates.ts b/packages/mcp/src/tools/packTemplates.ts index 7dd7a284ce..51b5c6f984 100644 --- a/packages/mcp/src/tools/packTemplates.ts +++ b/packages/mcp/src/tools/packTemplates.ts @@ -177,6 +177,7 @@ export function registerPackTemplateTools(agent: AgentContext): void { category: z.nativeEnum(ItemCategory).optional(), consumable: z.boolean().optional(), worn: z.boolean().optional(), + image: z.string().optional(), notes: z.string().optional(), }, }, From 83c999217ba1be55c6a39e1bab4559c607f9e5d3 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 20:43:58 -0600 Subject: [PATCH 23/30] =?UTF-8?q?=F0=9F=90=9B=20fix:=20drop=20=E2=98=85=20?= =?UTF-8?q?from=20landing=20OG=20image=20+=20harden=20font=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/guides/scripts/generate-og-images.ts | 13 +++++++++++++ apps/landing/lib/og-image.tsx | 2 +- apps/landing/scripts/generate-og-images.ts | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/guides/scripts/generate-og-images.ts b/apps/guides/scripts/generate-og-images.ts index 104bd96ed6..c2f63af508 100644 --- a/apps/guides/scripts/generate-og-images.ts +++ b/apps/guides/scripts/generate-og-images.ts @@ -23,6 +23,19 @@ import { createElement } from 'react'; import { getAllPosts } from '../lib/mdx-static'; import { getGuidesOgImageElement, getPostOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; +// @vercel/og auto-fetches Google Fonts when it encounters glyphs outside its +// bundled Latin coverage. CF Pages' build network occasionally returns 4xx for +// fonts.googleapis.com, killing the build. Intercept and return an empty 404 +// so loadGoogleFont gives up cleanly and ImageResponse falls back to bundled. +const originalFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.includes('fonts.googleapis.com') || url.includes('fonts.gstatic.com')) { + return new Response(null, { status: 404 }); + } + return originalFetch(input, init); +}) as typeof fetch; + const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public'); const OG_DIR = path.join(PUBLIC_DIR, 'og'); diff --git a/apps/landing/lib/og-image.tsx b/apps/landing/lib/og-image.tsx index f60dab071b..8dbd48a200 100644 --- a/apps/landing/lib/og-image.tsx +++ b/apps/landing/lib/og-image.tsx @@ -78,7 +78,7 @@ export function getLandingOgImageElement(): ReactElement { gap: '48px', }} > - {['10K+ Users', '4.8★ Rating', '100% Free'].map((stat) => ( + {['10K+ Users', '4.8/5 Rating', '100% Free'].map((stat) => (
{ + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.includes('fonts.googleapis.com') || url.includes('fonts.gstatic.com')) { + return new Response(null, { status: 404 }); + } + return originalFetch(input, init); +}) as typeof fetch; + const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public'); async function generateOgImages(): Promise { From df7087476ab2ac3edf9d2612d052a2677400e3a8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 20:44:42 -0600 Subject: [PATCH 24/30] =?UTF-8?q?=F0=9F=9A=A8=20lint:=20swap=20raw=20typeo?= =?UTF-8?q?f=20for=20@packrat/guards=20isString=20in=20OG=20fetch=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/guides/scripts/generate-og-images.ts | 3 ++- apps/landing/scripts/generate-og-images.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/guides/scripts/generate-og-images.ts b/apps/guides/scripts/generate-og-images.ts index c2f63af508..3576da6864 100644 --- a/apps/guides/scripts/generate-og-images.ts +++ b/apps/guides/scripts/generate-og-images.ts @@ -18,6 +18,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { isString } from '@packrat/guards'; import { ImageResponse } from 'next/og'; import { createElement } from 'react'; import { getAllPosts } from '../lib/mdx-static'; @@ -29,7 +30,7 @@ import { getGuidesOgImageElement, getPostOgImageElement, OG_IMAGE_SIZE } from '. // so loadGoogleFont gives up cleanly and ImageResponse falls back to bundled. const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = isString(input) ? input : input instanceof URL ? input.href : input.url; if (url.includes('fonts.googleapis.com') || url.includes('fonts.gstatic.com')) { return new Response(null, { status: 404 }); } diff --git a/apps/landing/scripts/generate-og-images.ts b/apps/landing/scripts/generate-og-images.ts index 16cad3409e..27e94f4223 100644 --- a/apps/landing/scripts/generate-og-images.ts +++ b/apps/landing/scripts/generate-og-images.ts @@ -15,6 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { isString } from '@packrat/guards'; import { ImageResponse } from 'next/og'; import { createElement } from 'react'; import { getLandingOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; @@ -25,7 +26,7 @@ import { getLandingOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; // so loadGoogleFont gives up cleanly and ImageResponse falls back to bundled. const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = isString(input) ? input : input instanceof URL ? input.href : input.url; if (url.includes('fonts.googleapis.com') || url.includes('fonts.gstatic.com')) { return new Response(null, { status: 404 }); } From 29d44a553616ab35cfc7d1e7d88931f6764d6d9f Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 20:47:26 -0600 Subject: [PATCH 25/30] =?UTF-8?q?=F0=9F=94=92=20security:=20parse=20URL=20?= =?UTF-8?q?hostname=20for=20font=20intercept=20(CodeQL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged url.includes('fonts.googleapis.com') as incomplete URL substring sanitization. Parse URL and check hostname exact-match instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/guides/scripts/generate-og-images.ts | 11 ++++++++--- apps/landing/scripts/generate-og-images.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/guides/scripts/generate-og-images.ts b/apps/guides/scripts/generate-og-images.ts index 3576da6864..6792b61d9e 100644 --- a/apps/guides/scripts/generate-og-images.ts +++ b/apps/guides/scripts/generate-og-images.ts @@ -28,11 +28,16 @@ import { getGuidesOgImageElement, getPostOgImageElement, OG_IMAGE_SIZE } from '. // bundled Latin coverage. CF Pages' build network occasionally returns 4xx for // fonts.googleapis.com, killing the build. Intercept and return an empty 404 // so loadGoogleFont gives up cleanly and ImageResponse falls back to bundled. +const FONT_HOSTS = new Set(['fonts.googleapis.com', 'fonts.gstatic.com']); const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = isString(input) ? input : input instanceof URL ? input.href : input.url; - if (url.includes('fonts.googleapis.com') || url.includes('fonts.gstatic.com')) { - return new Response(null, { status: 404 }); + const href = isString(input) ? input : input instanceof URL ? input.href : input.url; + try { + if (FONT_HOSTS.has(new URL(href).hostname)) { + return new Response(null, { status: 404 }); + } + } catch { + // Not a parseable absolute URL — fall through to the real fetch. } return originalFetch(input, init); }) as typeof fetch; diff --git a/apps/landing/scripts/generate-og-images.ts b/apps/landing/scripts/generate-og-images.ts index 27e94f4223..fa0ae6be3d 100644 --- a/apps/landing/scripts/generate-og-images.ts +++ b/apps/landing/scripts/generate-og-images.ts @@ -24,11 +24,16 @@ import { getLandingOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; // bundled Latin coverage. CF Pages' build network occasionally returns 4xx for // fonts.googleapis.com, killing the build. Intercept and return an empty 404 // so loadGoogleFont gives up cleanly and ImageResponse falls back to bundled. +const FONT_HOSTS = new Set(['fonts.googleapis.com', 'fonts.gstatic.com']); const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = isString(input) ? input : input instanceof URL ? input.href : input.url; - if (url.includes('fonts.googleapis.com') || url.includes('fonts.gstatic.com')) { - return new Response(null, { status: 404 }); + const href = isString(input) ? input : input instanceof URL ? input.href : input.url; + try { + if (FONT_HOSTS.has(new URL(href).hostname)) { + return new Response(null, { status: 404 }); + } + } catch { + // Not a parseable absolute URL — fall through to the real fetch. } return originalFetch(input, init); }) as typeof fetch; From e0dcc3617dca92e8daf3533d16d3653ccd233e8c Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 23:22:22 -0600 Subject: [PATCH 26/30] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20chore:=20pin=20bun?= =?UTF-8?q?=201.3.14=20via=20.bun-version=20+=20packageManager=20+=20engin?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CF Pages' default bun didn't interpolate $PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN in bunfig.toml scopes, causing a 403 on @packrat-ai/nativewindui during the preinstall. Pinning bun to a version that supports env-var interpolation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .bun-version | 1 + package.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .bun-version diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000000..085c0f2666 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.14 diff --git a/package.json b/package.json index a5fd935e4c..9b468c74cd 100644 --- a/package.json +++ b/package.json @@ -71,9 +71,9 @@ "semver": "catalog:", "sort-package-json": "^3.6.1" }, - "packageManager": "bun@1.3.10", + "packageManager": "bun@1.3.14", "engines": { - "bun": ">=1.3.10", + "bun": ">=1.3.14", "node": ">=24.0.0" }, "catalog": { From 9b71b6953eaedae21bd41c2ed24f8671073971da Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sat, 16 May 2026 23:57:20 -0600 Subject: [PATCH 27/30] =?UTF-8?q?=F0=9F=9A=9A=20schemas:=20move=20AddPackI?= =?UTF-8?q?temFromCatalogBodySchema=20into=20@packrat/schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-push lint enforces all Zod schemas live in packages/schemas/src/. T8's inline schema moves to packs.ts as AddPackItemFromCatalogBodySchema. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/packs/index.ts | 15 ++------------- packages/schemas/src/packs.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 5be2b118c5..e32a3a6997 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -25,6 +25,7 @@ import { import { AnalyzeImageRequestSchema } from '@packrat/schemas/imageDetection'; import { AddPackItemBodySchema, + AddPackItemFromCatalogBodySchema, CreatePackBodySchema, GapAnalysisRequestSchema, PackItemSchema, @@ -48,18 +49,6 @@ import { import { Elysia, NotFoundError, status } from 'elysia'; import { z } from 'zod'; -// Lean payload for /items/from-catalog. Name/weight/weightUnit/category get -// hydrated server-side from the catalog row. -const AddPackItemFromCatalogSchema = z.object({ - catalogItemId: z.number().int().positive(), - quantity: z.number().int().positive().optional(), - notes: z.string().optional(), - consumable: z.boolean().optional(), - worn: z.boolean().optional(), - // Optional override — usually the catalog category is fine. - category: z.string().optional(), -}); - export const packsRoutes = new Elysia({ prefix: '/packs' }) .use(authPlugin) .use(adminAuthPlugin) @@ -777,7 +766,7 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s }, { params: z.object({ packId: z.string() }), - body: AddPackItemFromCatalogSchema, + body: AddPackItemFromCatalogBodySchema, isAuthenticated: true, detail: { tags: ['Pack Items'], diff --git a/packages/schemas/src/packs.ts b/packages/schemas/src/packs.ts index e60f617dd0..05140420fa 100644 --- a/packages/schemas/src/packs.ts +++ b/packages/schemas/src/packs.ts @@ -180,6 +180,18 @@ export const AddPackItemBodySchema = CreatePackItemRequestSchema.extend({ id: z.string().trim().min(1).optional(), }); +// Lean payload for /items/from-catalog. Name/weight/weightUnit/category get +// hydrated server-side from the catalog row. +export const AddPackItemFromCatalogBodySchema = z.object({ + catalogItemId: z.number().int().positive(), + quantity: z.number().int().positive().optional(), + notes: z.string().optional(), + consumable: z.boolean().optional(), + worn: z.boolean().optional(), + // Optional override — usually the catalog category is fine. + category: z.string().optional(), +}); + export const UpdatePackBodySchema = UpdatePackRequestSchema.extend({ localUpdatedAt: z.string().datetime().optional(), }); From 67e6afea9d6e1113276a7c800879b3d4e0e50de8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 17 May 2026 00:10:50 -0600 Subject: [PATCH 28/30] =?UTF-8?q?=E2=8F=AA=20revert:=20T8/T9=20=E2=80=94?= =?UTF-8?q?=20defer=20client-vs-server=20ID=20split=20to=20feat/client-uui?= =?UTF-8?q?d-split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T9 (optional id + server mintId) and T8 (POST /packs/:id/items/from-catalog) proved hacky on a single id column with dual ownership. Per design doc shipped in #2435 (docs/design/client-uuid-split.md), the cleaner pattern is two columns: server-owned `id` (always minted) + client-owned `clientUuid` (idempotency key with UNIQUE(user_id, client_uuid)). Reverting T8/T9 here so #2433 ships only T1-T6 + T10 (lean endpoints + API thickening). The optional-id work is preserved on feat/client-uuid-split for the proper migration. Removed: - packages/api/src/utils/ids.ts (mintId helper) - packages/api/src/routes/packs/index.ts: /:packId/items/from-catalog endpoint, mintId fallbacks - packages/api/src/routes/trips/index.ts: mintId fallback - packages/api/src/routes/trailConditions/reports.ts: mintId fallback, 23505-only-if-data.id branch - packages/mcp/src/tools/packs.ts: add_pack_item_from_catalog tool - packages/schemas/src/packs.ts: AddPackItemFromCatalogBodySchema - packages/schemas/{trips,packs,trailConditions}.ts: id back to z.string() (required) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/packs/index.ts | 92 +------------------ .../api/src/routes/trailConditions/reports.ts | 9 +- packages/api/src/routes/trips/index.ts | 5 +- packages/api/src/utils/ids.ts | 19 ---- packages/mcp/src/tools/packs.ts | 32 ------- packages/schemas/src/packs.ts | 18 +--- packages/schemas/src/trailConditions.ts | 9 +- packages/schemas/src/trips.ts | 4 +- 8 files changed, 10 insertions(+), 178 deletions(-) delete mode 100644 packages/api/src/utils/ids.ts diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index e32a3a6997..8b915d1a3c 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -12,7 +12,6 @@ import { getPackDetails } from '@packrat/api/utils/DbUtils'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; import { getPresignedUrl } from '@packrat/api/utils/getPresignedUrl'; -import { mintId } from '@packrat/api/utils/ids'; import { catalogItems, type NewPack, @@ -25,7 +24,6 @@ import { import { AnalyzeImageRequestSchema } from '@packrat/schemas/imageDetection'; import { AddPackItemBodySchema, - AddPackItemFromCatalogBodySchema, CreatePackBodySchema, GapAnalysisRequestSchema, PackItemSchema, @@ -91,14 +89,12 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) const db = createDb(); const data = body; - const packId = data.id ?? mintId('p'); - // Zod validates all fields at runtime; cast through the Standard Schema // inference gap so drizzle's insert accepts the values. const [newPack] = await db .insert(packs) .values({ - id: packId, + id: data.id, userId: user.userId, name: data.name, description: data.description, @@ -419,7 +415,7 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) const packWeightHistoryEntry = await db .insert(packWeightHistory) .values({ - id: data.id ?? mintId('w'), + id: data.id, packId: params.packId, userId: user.userId, weight: data.weight, @@ -638,7 +634,7 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s getEnv(); if (!OPENAI_API_KEY) return status(400, { error: 'OpenAI API key not configured' }); - const itemId = data.id ?? mintId('i'); + const itemId = data.id; const embeddingText = getEmbeddingText(data); const embedding = await generateEmbedding({ @@ -694,88 +690,6 @@ Limit to maximum 6 recommendations, prioritizing the most important gaps. Only s }, ) - // Add item from catalog — server hydrates name/weight/category from the - // catalog row so the caller only supplies the link plus per-pack metadata - // (quantity, notes, worn, consumable). - .post( - '/:packId/items/from-catalog', - async ({ params, body, user }) => { - const db = createDb(); - const packId = params.packId; - - const pack = await db.query.packs.findFirst({ - where: and(eq(packs.id, packId), eq(packs.userId, user.userId)), - columns: { id: true }, - }); - if (!pack) return status(404, { error: 'Pack not found' }); - - const catalog = await db.query.catalogItems.findFirst({ - where: eq(catalogItems.id, body.catalogItemId), - }); - if (!catalog) { - return status(404, { error: `Catalog item ${body.catalogItemId} not found` }); - } - // `name` is NOT NULL in pack_items; a catalog row missing it would - // produce a Postgres `23502 not_null_violation` deep in the insert. - // Fail fast with a 422 so the caller sees the misconfigured row. - if (!catalog.name || catalog.name.trim().length === 0) { - return status(422, { - error: `Catalog item ${body.catalogItemId} has no name and cannot be added to a pack`, - }); - } - - const id = mintId('i'); - const now = new Date(); - const [newItem] = await db - .insert(packItems) - .values({ - id, - packId, - catalogItemId: catalog.id, - name: catalog.name, - description: catalog.description ?? null, - weight: catalog.weight ?? 0, - weightUnit: catalog.weightUnit ?? 'g', - quantity: body.quantity ?? 1, - category: body.category ?? catalog.categories?.[0] ?? 'Uncategorized', - consumable: body.consumable ?? false, - worn: body.worn ?? false, - image: catalog.images?.[0] ?? null, - notes: body.notes ?? null, - userId: user.userId, - embedding: catalog.embedding, - localCreatedAt: now, - localUpdatedAt: now, - } as NewPackItem) // safe-cast: object literal matches NewPackItem; embedding field uses the narrower type - .returning(); - - await db.update(packs).set({ updatedAt: new Date() }).where(eq(packs.id, packId)); - - if (!newItem) return status(400, { error: 'Failed to create item' }); - - return status(201, { - ...newItem, - consumable: newItem.consumable ?? false, - worn: newItem.worn ?? false, - deleted: newItem.deleted ?? false, - createdAt: newItem.createdAt.toISOString(), - updatedAt: newItem.updatedAt.toISOString(), - embedding: undefined, - templateItemId: newItem.templateItemId ?? null, - }); - }, - { - params: z.object({ packId: z.string() }), - body: AddPackItemFromCatalogBodySchema, - isAuthenticated: true, - detail: { - tags: ['Pack Items'], - summary: 'Add a catalog item to a pack', - security: [{ bearerAuth: [] }], - }, - }, - ) - // Get single item .get( '/items/:itemId', diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index 1da5744999..22b8722d1e 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -1,6 +1,5 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; -import { mintId } from '@packrat/api/utils/ids'; import type { NewTrailConditionReport } from '@packrat/db'; import { trailConditionReports } from '@packrat/db'; import { @@ -85,13 +84,12 @@ export const trailConditionRoutes = new Elysia() async ({ body, user }) => { const db = createDb(); const data = body; - const reportId = data.id ?? mintId('tcr'); try { const [newReport] = await db .insert(trailConditionReports) .values({ - id: reportId, + id: data.id, trailName: data.trailName, trailRegion: data.trailRegion ?? null, surface: data.surface, @@ -114,10 +112,7 @@ export const trailConditionRoutes = new Elysia() return toReportResponse(newReport); } catch (error) { const pgCode = (error as { code?: string })?.code; - // 23505 is the unique-violation path: client-supplied id already in - // use. Server-minted ids are statistically collision-free, so this - // only matters when the caller passed one. - if (pgCode === '23505' && data.id) { + if (pgCode === '23505') { const existing = await db.query.trailConditionReports.findFirst({ where: and( eq(trailConditionReports.id, data.id), diff --git a/packages/api/src/routes/trips/index.ts b/packages/api/src/routes/trips/index.ts index 8213594723..7ddd6c66c3 100644 --- a/packages/api/src/routes/trips/index.ts +++ b/packages/api/src/routes/trips/index.ts @@ -1,6 +1,5 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; -import { mintId } from '@packrat/api/utils/ids'; import { trips } from '@packrat/db'; import { CreateTripBodySchema, TripSchema, UpdateTripBodySchema } from '@packrat/schemas/trips'; import { and, eq } from 'drizzle-orm'; @@ -46,13 +45,11 @@ export const tripsRoutes = new Elysia({ prefix: '/trips' }) const db = createDb(); const data = body; - const tripId = data.id ?? mintId('t'); - try { const [newTrip] = await db .insert(trips) .values({ - id: tripId, + id: data.id, userId: user.userId, name: data.name, description: data.description ?? null, diff --git a/packages/api/src/utils/ids.ts b/packages/api/src/utils/ids.ts deleted file mode 100644 index 597b1edb72..0000000000 --- a/packages/api/src/utils/ids.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Server-side ID minting for offline-first stores. - * - * The mobile / desktop stores supply their own IDs so Legend State can - * write rows before sync — those client-supplied IDs are kept as-is. - * Lean callers (MCP, CLI, web) can omit `id` and let the server mint one - * with the right prefix here, which keeps the format stable across both - * sources. - * - * Format: `_<12-hex>`, e.g. `p_a1b2c3d4e5f6`. - */ - -const STRIP_HYPHENS = /-/g; - -const SHORT_LENGTH = 12; - -export function mintId(prefix: string): string { - return `${prefix}_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, SHORT_LENGTH)}`; -} diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index 757906b371..143b7a2d69 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -205,38 +205,6 @@ export function registerPackTools(agent: AgentContext): void { ), ); - // ── Add a catalog item to a pack (lean) ────────────────────────────────── - // Server hydrates name/weight/weightUnit/category from the catalog row. - - agent.server.registerTool( - 'add_pack_item_from_catalog', - { - description: - 'Add a catalog item to a pack. Server copies name/weight/category from the catalog row; you just supply the catalog ID and per-pack metadata (quantity, notes, worn, consumable).', - inputSchema: { - pack_id: z.string(), - catalog_item_id: z.number().int().positive(), - quantity: z.number().int().min(1).optional(), - notes: z.string().optional(), - is_consumable: z.boolean().optional(), - is_worn: z.boolean().optional(), - category: z.string().optional().describe('Override catalog category if needed'), - }, - }, - async ({ pack_id, catalog_item_id, quantity, notes, is_consumable, is_worn, category }) => - call( - agent.api.user.packs({ packId: pack_id }).items['from-catalog'].post({ - catalogItemId: catalog_item_id, - quantity, - notes, - consumable: is_consumable, - worn: is_worn, - category, - }), - { action: 'add catalog item to pack', resourceHint: `pack ${pack_id}` }, - ), - ); - // ── Update pack item ────────────────────────────────────────────────────── agent.server.registerTool( diff --git a/packages/schemas/src/packs.ts b/packages/schemas/src/packs.ts index 05140420fa..fca76c115a 100644 --- a/packages/schemas/src/packs.ts +++ b/packages/schemas/src/packs.ts @@ -168,28 +168,14 @@ export const GapAnalysisResponseSchema = z.object({ // Body schemas mirroring the inline route schemas (exported so stores/clients // can use ApiBody<> or direct z.infer<> without importing from route files). -// id optional — server mints if absent (lean callers). Offline-first -// stores (mobile) keep supplying client-side IDs for sync. export const CreatePackBodySchema = CreatePackRequestSchema.extend({ - id: z.string().trim().min(1).optional(), + id: z.string(), localCreatedAt: z.string().datetime(), localUpdatedAt: z.string().datetime(), }); export const AddPackItemBodySchema = CreatePackItemRequestSchema.extend({ - id: z.string().trim().min(1).optional(), -}); - -// Lean payload for /items/from-catalog. Name/weight/weightUnit/category get -// hydrated server-side from the catalog row. -export const AddPackItemFromCatalogBodySchema = z.object({ - catalogItemId: z.number().int().positive(), - quantity: z.number().int().positive().optional(), - notes: z.string().optional(), - consumable: z.boolean().optional(), - worn: z.boolean().optional(), - // Optional override — usually the catalog category is fine. - category: z.string().optional(), + id: z.string(), }); export const UpdatePackBodySchema = UpdatePackRequestSchema.extend({ diff --git a/packages/schemas/src/trailConditions.ts b/packages/schemas/src/trailConditions.ts index 57a7d5979a..e36f13a759 100644 --- a/packages/schemas/src/trailConditions.ts +++ b/packages/schemas/src/trailConditions.ts @@ -32,14 +32,7 @@ export const TrailConditionReportSchema = z.object({ export type TrailConditionReport = z.infer; export const CreateTrailConditionReportRequestSchema = z.object({ - // id optional — server mints if absent (lean callers). Offline-first - // stores keep supplying client-side IDs for sync. min(1) rejects ''. - id: z - .string() - .trim() - .min(1) - .optional() - .describe('Client-generated report ID; server mints when absent'), + id: z.string().describe('Client-generated report ID'), trailName: z.string().min(1), trailRegion: z.string().optional().nullable(), surface: TrailSurfaceSchema, diff --git a/packages/schemas/src/trips.ts b/packages/schemas/src/trips.ts index 917469cd46..3e63266c05 100644 --- a/packages/schemas/src/trips.ts +++ b/packages/schemas/src/trips.ts @@ -32,9 +32,7 @@ export const TripSchema = z.object({ export type Trip = z.infer; export const CreateTripBodySchema = z.object({ - // id optional — server mints if absent (lean callers). Offline-first - // stores keep supplying client-side IDs for sync. min(1) rejects ''. - id: z.string().trim().min(1).optional(), + id: z.string(), name: z.string().min(1).max(255), description: z.string().nullable().optional(), notes: z.string().nullable().optional(), From 56ad9ea8a8f4a9e0540fad3ba069bf4da89b4db7 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 17 May 2026 00:23:58 -0600 Subject: [PATCH 29/30] =?UTF-8?q?=F0=9F=94=A5=20fix:=20typecheck=20failure?= =?UTF-8?q?s=20after=20T9=20revert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schemas/packTemplates.ts: revert pack template + item id to required (T9 leftover) - schemas/admin.ts: drop .optional().default() on AnalyticsPeriodSchema (T1 thickening — keeps Treaty types optional, handler defaults stay) - api/routes/packs/index.ts: weight-history body uses shared CreatePackWeightHistoryBodySchema (id required, no inline schema lint violation) - api/routes/admin/trails.ts: includeDeleted via queryBoolean() so CLI/admin SPA can pass boolean; also drop limit/offset defaults - cli/api/ids.ts: switch shortId from truncated UUIDv4 hex to Bun.randomUUIDv7 — time-ordered, full 128-bit, better B-tree locality - cli/commands/{packs/create,trips/index,templates/index}.ts: mint client-side id via shortId() so create calls satisfy the required-id schemas Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api/src/routes/admin/trails.ts | 10 ++++++---- packages/api/src/routes/packs/index.ts | 7 ++----- packages/cli/src/api/ids.ts | 11 +++++------ packages/cli/src/commands/packs/create.ts | 5 +++-- packages/cli/src/commands/templates/index.ts | 3 ++- packages/cli/src/commands/trips/index.ts | 3 ++- packages/schemas/src/admin.ts | 6 ++++-- packages/schemas/src/packTemplates.ts | 6 ++---- 8 files changed, 26 insertions(+), 25 deletions(-) diff --git a/packages/api/src/routes/admin/trails.ts b/packages/api/src/routes/admin/trails.ts index 5a845ec045..b3a3fb20d1 100644 --- a/packages/api/src/routes/admin/trails.ts +++ b/packages/api/src/routes/admin/trails.ts @@ -1,5 +1,6 @@ import { createDb, createOsmDb } from '@packrat/api/db'; import { trailConditionReports, users } from '@packrat/db'; +import { queryBoolean } from '@packrat/guards'; import { AdminErrorResponses, SuccessSchema, @@ -243,7 +244,7 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) const limit = query.limit ?? 50; const offset = query.offset ?? 0; const search = query.q; - const includeDeleted = query.includeDeleted === 'true'; + const includeDeleted = query.includeDeleted ?? false; try { const deletedFilter = includeDeleted ? undefined : eq(trailConditionReports.deleted, false); @@ -300,9 +301,10 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) { query: z.object({ q: z.string().optional(), - limit: z.coerce.number().int().min(1).max(100).optional().default(50), - offset: z.coerce.number().int().min(0).optional().default(0), - includeDeleted: z.string().optional(), + // Handler defaults limit to 50, offset to 0; keep schema truly optional. + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), + includeDeleted: queryBoolean(), }), response: { 200: TrailConditionsListSchema, ...AdminErrorResponses }, detail: { tags: ['Admin'], summary: 'List all trail condition reports' }, diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 6233f28f4b..4fe6e10e3f 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -25,6 +25,7 @@ import { AnalyzeImageRequestSchema } from '@packrat/schemas/imageDetection'; import { AddPackItemBodySchema, CreatePackBodySchema, + CreatePackWeightHistoryBodySchema, GapAnalysisRequestSchema, PackItemSchema, PackWithWeightsSchema, @@ -434,11 +435,7 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) }, { params: z.object({ packId: z.string() }), - body: z.object({ - id: z.string().optional(), - weight: z.number(), - localCreatedAt: z.string().datetime(), - }), + body: CreatePackWeightHistoryBodySchema, isAuthenticated: true, detail: { tags: ['Packs'], diff --git a/packages/cli/src/api/ids.ts b/packages/cli/src/api/ids.ts index 035f1034f2..d6972f0327 100644 --- a/packages/cli/src/api/ids.ts +++ b/packages/cli/src/api/ids.ts @@ -1,13 +1,12 @@ /** - * ID helpers for client-side creation. The API mostly expects the client to - * supply IDs (so offline-first stores can write before sync). Match the format - * used by the mobile app / MCP: `_<12-hex>`. + * ID helpers for client-side creation. The API expects the client to supply + * IDs (so offline-first stores can write before sync). The CLI runs under + * Bun, so we use the native UUIDv7 generator — time-ordered for good B-tree + * locality if/when the id becomes the actual PK on disk. */ -const STRIP_HYPHENS = /-/g; - export function shortId(prefix: string): string { - return `${prefix}_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 12)}`; + return `${prefix}_${Bun.randomUUIDv7()}`; } export function nowIso(): string { diff --git a/packages/cli/src/commands/packs/create.ts b/packages/cli/src/commands/packs/create.ts index b2d4b300b9..7c80733469 100644 --- a/packages/cli/src/commands/packs/create.ts +++ b/packages/cli/src/commands/packs/create.ts @@ -2,7 +2,7 @@ import { toRecord } from '@packrat/guards'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; -import { nowIso } from '../../api/ids'; +import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; export default defineCommand({ @@ -31,6 +31,7 @@ export default defineCommand({ : undefined; const pack = await runApi( client.packs.post({ + id: shortId('p'), name: args.name, description: args.description, category: args.category, @@ -41,6 +42,6 @@ export default defineCommand({ }), { action: 'create pack' }, ); - consola.success(`Created pack ${toRecord(pack).id ?? '(server-minted id)'}`); + consola.success(`Created pack ${toRecord(pack).id ?? '(unknown id)'}`); }, }); diff --git a/packages/cli/src/commands/templates/index.ts b/packages/cli/src/commands/templates/index.ts index d81319e14f..83a5647c1d 100644 --- a/packages/cli/src/commands/templates/index.ts +++ b/packages/cli/src/commands/templates/index.ts @@ -1,7 +1,7 @@ import { toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { nowIso } from '../../api/ids'; +import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printTable } from '../../shared'; @@ -60,6 +60,7 @@ const createCmd = defineCommand({ const now = nowIso(); const data = await runApi( client['pack-templates'].post({ + id: shortId('pt'), name: args.name, description: args.description, category: args.category, diff --git a/packages/cli/src/commands/trips/index.ts b/packages/cli/src/commands/trips/index.ts index 8f463bef36..38a8e3f2cd 100644 --- a/packages/cli/src/commands/trips/index.ts +++ b/packages/cli/src/commands/trips/index.ts @@ -1,7 +1,7 @@ import { toRecord, toRecordArray } from '@packrat/guards'; import { defineCommand } from 'citty'; import { getUserClient } from '../../api/client'; -import { nowIso } from '../../api/ids'; +import { nowIso, shortId } from '../../api/ids'; import { requireAuth, runApi } from '../../api/run'; import { printSummary, printTable } from '../../shared'; @@ -87,6 +87,7 @@ const createCmd = defineCommand({ : null; const trip = await runApi( client.trips.post({ + id: shortId('t'), name: args.name, description: args.description, location, diff --git a/packages/schemas/src/admin.ts b/packages/schemas/src/admin.ts index e024c0eecf..17b2fe4a72 100644 --- a/packages/schemas/src/admin.ts +++ b/packages/schemas/src/admin.ts @@ -113,9 +113,11 @@ export const CatalogUpdateSchema = z.object({ id: z.number(), name: z.string() } // ─── Analytics — Platform ───────────────────────────────────────────────────── +// Handler defaults period to 'month' and range to 12; keep schema truly +// optional so the Treaty type doesn't mark these as required-with-default. export const AnalyticsPeriodSchema = z.object({ - period: z.enum(['day', 'week', 'month']).optional().default('month'), - range: z.coerce.number().int().min(1).max(365).optional().default(12), + period: z.enum(['day', 'week', 'month']).optional(), + range: z.coerce.number().int().min(1).max(365).optional(), }); export const GrowthPointSchema = z.object({ diff --git a/packages/schemas/src/packTemplates.ts b/packages/schemas/src/packTemplates.ts index e64eed7bbc..445deaa590 100644 --- a/packages/schemas/src/packTemplates.ts +++ b/packages/schemas/src/packTemplates.ts @@ -50,8 +50,7 @@ export const PackTemplateWithItemsSchema = PackTemplateSchema.extend({ }); export const CreatePackTemplateRequestSchema = z.object({ - // id optional so the server can mint for lean callers. - id: z.string().optional(), + id: z.string(), name: z.string().min(1).max(255), description: z.string().optional(), category: z.string().min(1), @@ -74,8 +73,7 @@ export const UpdatePackTemplateRequestSchema = z.object({ }); export const CreatePackTemplateItemRequestSchema = z.object({ - // id optional so the server can mint for lean callers. - id: z.string().optional(), + id: z.string(), name: z.string().min(1).max(255), description: z.string().optional(), weight: z.number().min(0), From db2a0bc6613a7939ca5f8d378e279c7448c01e02 Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Sun, 17 May 2026 00:29:39 -0600 Subject: [PATCH 30/30] =?UTF-8?q?=F0=9F=94=80=20chore(cli):=20use=20uuid?= =?UTF-8?q?=20npm=20package=20(v7)=20instead=20of=20Bun.randomUUIDv7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime-portable: same helper works in Node, Workers, browser. Useful if this ever moves out of @packrat/cli (e.g., shared with @packrat/mcp on Cloudflare Workers, which doesn't have Bun's globals). Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 10 +++++++++- packages/cli/package.json | 4 +++- packages/cli/src/api/ids.ts | 10 ++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 977558afcc..15c98faecd 100644 --- a/bun.lock +++ b/bun.lock @@ -565,10 +565,12 @@ "citty": "^0.2.1", "cli-table3": "^0.6.5", "consola": "catalog:", + "uuid": "^11.0.5", "zod": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", + "@types/uuid": "^11.0.0", }, }, "packages/config": { @@ -2221,6 +2223,8 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -4715,7 +4719,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], @@ -5097,6 +5101,8 @@ "@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "@types/uuid/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], "@typescript-eslint/typescript-estree/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], @@ -5465,6 +5471,8 @@ "react-native-blob-util/glob": ["glob@13.0.1", "", { "dependencies": { "minimatch": "^10.1.2", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w=="], + "react-native-blob-util/uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + "react-native-reanimated/react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.2.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q=="], "react-native-reanimated/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index cee17c5cfe..aa837262c3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,9 +20,11 @@ "citty": "^0.2.1", "cli-table3": "^0.6.5", "consola": "catalog:", + "uuid": "^11.0.5", "zod": "catalog:" }, "devDependencies": { - "@types/bun": "catalog:" + "@types/bun": "catalog:", + "@types/uuid": "^11.0.0" } } diff --git a/packages/cli/src/api/ids.ts b/packages/cli/src/api/ids.ts index d6972f0327..d1a6b801e9 100644 --- a/packages/cli/src/api/ids.ts +++ b/packages/cli/src/api/ids.ts @@ -1,12 +1,14 @@ /** * ID helpers for client-side creation. The API expects the client to supply - * IDs (so offline-first stores can write before sync). The CLI runs under - * Bun, so we use the native UUIDv7 generator — time-ordered for good B-tree - * locality if/when the id becomes the actual PK on disk. + * IDs (so offline-first stores can write before sync). UUIDv7 is time-ordered + * for good B-tree locality if/when the id becomes the actual PK on disk. + * Using the `uuid` npm package (not Bun.randomUUIDv7) so the same helper + * works in any JS runtime — useful if this ever moves to MCP / Workers. */ +import { v7 as uuidv7 } from 'uuid'; export function shortId(prefix: string): string { - return `${prefix}_${Bun.randomUUIDv7()}`; + return `${prefix}_${uuidv7()}`; } export function nowIso(): string {