diff --git a/.github/copilot-setup-steps.yml b/.github/copilot-setup-steps.yml deleted file mode 100644 index aa27d3ccec..0000000000 --- a/.github/copilot-setup-steps.yml +++ /dev/null @@ -1,10 +0,0 @@ -steps: - - name: Install Bun - run: | - curl -fsSL https://bun.sh/install | bash - echo "$HOME/.bun/bin" >> $GITHUB_PATH - - - name: Install dependencies - run: bun install --frozen-lockfile - env: - PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index f5b83993b4..ad520e25c6 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -35,7 +35,7 @@ jobs: - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install - + run: bun install --frozen-lockfile + - name: Run API tests run: bun run --cwd packages/api test diff --git a/.github/workflows/biome.yml b/.github/workflows/biome.yml deleted file mode 100644 index 3bf59705da..0000000000 --- a/.github/workflows/biome.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Biome Check - -on: - pull_request: - branches: ["**"] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - biome: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - cache: true - - name: Install dependencies - env: - PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install - - name: Run Biome check - run: bun biome check - - name: Check circular dependencies - run: bun scripts/lint/no-circular-deps.ts - - name: Check for duplicate/catalog dependencies - run: bun scripts/lint/no-duplicate-deps.ts - - name: Check package.json ordering - run: bun scripts/format/sort-package-json.ts --check - - name: Run Expo Doctor - run: bunx expo-doctor - working-directory: apps/expo - continue-on-error: true diff --git a/.github/workflows/check-types.yml b/.github/workflows/check-types.yml deleted file mode 100644 index 49b6587a68..0000000000 --- a/.github/workflows/check-types.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Check Types - -on: - pull_request: - branches: ["**"] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - check-types: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - cache: true - - name: Install dependencies - env: - PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install - - name: Check Types - run: bun check-types diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000000..6d14d0bdb5 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,59 @@ +name: Checks + +on: + pull_request: + branches: ['**'] + workflow_dispatch: + inputs: + fix: + description: 'Apply Biome autofixes (runs bun lint and commits changes)' + required: false + default: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Required for optional workflow_dispatch autofix commits. + contents: write + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + cache: true + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + - name: Run Biome (check mode) + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.fix == true) }} + run: bun biome check + - name: Run Biome (autofix mode) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.fix == true }} + run: bun lint + - name: Commit Biome autofixes + if: ${{ github.event_name == 'workflow_dispatch' && inputs.fix == true }} + uses: stefanzweifel/git-auto-commit-action@v6 + with: + commit_message: 'chore: apply biome autofixes via checks workflow' + - name: Check circular dependencies + run: bun scripts/lint/no-circular-deps.ts + - name: Check for duplicate/catalog dependencies + run: bun scripts/lint/no-duplicate-deps.ts + - name: Check package.json ordering + run: bun scripts/format/sort-package-json.ts --check + - name: Check types + run: bun check-types + - name: Run Expo Doctor + run: bunx expo-doctor + working-directory: apps/expo + continue-on-error: true + - name: Run React Doctor checks + run: bun check:react-doctor diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000000..ef4ace135e --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,109 @@ +name: "Copilot Setup Steps" + +# Run automatically when this file changes to validate it, and allow manual testing. +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + # Skip on fork PRs: environment secrets are not available to forked repositories, + # so the secret-validation step would always fail for external contributors. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false + # Secrets for this repo are stored in the 'copilot' environment. + environment: copilot + + # Minimum permissions needed. Copilot will be given its own token for its operations. + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + # Validate the private-package token before attempting install, so failures are + # actionable rather than opaque 401s buried in bun install output. + - name: Validate required secrets + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: | + if [ -z "$PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN" ]; then + echo "❌ PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN is not set." + echo "" + echo "This secret is required to install the private @packrat-ai/nativewindui" + echo "package from GitHub Package Registry (npm.pkg.github.com)." + echo "" + echo "Fix: Add PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN as a secret in the 'copilot'" + echo "GitHub Actions environment (Settings → Environments → copilot → Secrets)." + echo "The token needs the 'read:packages' scope." + exit 1 + fi + echo "✓ PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN is present" + + # Pin Node to the exact major required by package.json engines (>=24). + - name: Set up Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: "24" + + # Read the Bun version from package.json#packageManager to keep a single source of truth. + - name: Read Bun version from package.json + id: bun-version + run: | + BUN_VERSION=$(jq -r '.packageManager' package.json | sed 's/bun@//') + echo "version=$BUN_VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved Bun version: $BUN_VERSION" + + # Pin Bun to the exact version declared in package.json#packageManager. + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ steps.bun-version.outputs.version }} + cache: true + + # Sanity-check that the runner satisfies the repo's engine constraints. + - name: Verify runtime versions + run: | + echo "Bun: $(bun --version)" + echo "Node: $(node --version)" + + NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/') + if [ "$NODE_MAJOR" -lt 24 ]; then + echo "❌ Node.js $NODE_MAJOR is below the required minimum of 24." + exit 1 + fi + echo "✓ Runtime versions satisfy repo constraints" + + # Install all workspace dependencies. + # The preinstall hook (configure-deps.ts) checks PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN + # automatically, so no extra wrangling is needed here. + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + # Confirm the key CLI tools installed by the workspace are usable. + # Scoped to cloud-agent-safe tasks (lint/typecheck/tests); no mobile simulator tooling. + - name: Smoke check — verify toolchain + run: | + echo "=== Biome ===" + bunx biome --version + + echo "" + echo "=== TypeScript ===" + bunx tsc --version + + echo "" + echo "=== Wrangler (Cloudflare Workers CLI) ===" + bunx wrangler --version + + echo "" + echo "✓ Copilot environment is ready" + echo " Available tasks: bun lint · bun check-types · bun test:api:unit · bun test:expo · bun api" diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d0e7b784d6..ffd06d3a8d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -27,6 +27,9 @@ permissions: env: MAESTRO_VERSION: 2.3.0 + # Suppress the per-invocation "Anonymous analytics enabled" banner and + # the network round-trip it implies on every CI run. + MAESTRO_CLI_NO_ANALYTICS: "true" jobs: ios-e2e: @@ -37,13 +40,29 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: + # The E2E user is upserted into the dev DB by the seed step below, + # so both email and password are driven entirely by repo secrets. TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} steps: + - name: Verify E2E secrets are configured + run: | + missing=() + [ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") + [ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") + [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required E2E secrets missing: ${missing[*]}" + echo "::error::Set them via: gh secret set --repo PackRat-AI/PackRat" + exit 1 + fi + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + - name: Checkout repository uses: actions/checkout@v6 - + - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: @@ -55,10 +74,18 @@ jobs: bun-version: latest cache: true + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: | + node-modules-${{ runner.os }}- + - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Setup Expo uses: expo/expo-github-action@v8 @@ -72,6 +99,16 @@ jobs: distribution: temurin java-version: "17" + - name: Cache CocoaPods + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/CocoaPods + ~/.cocoapods + key: cocoapods-${{ runner.os }}-${{ hashFiles('apps/expo/package.json', 'apps/expo/app.config.ts', 'bun.lock') }} + restore-keys: | + cocoapods-${{ runner.os }}- + - name: Build iOS app for simulator run: | cd apps/expo @@ -178,16 +215,28 @@ jobs: run: | xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH" + - name: Seed E2E test user in dev DB + run: bun run --filter @packrat/api db:seed:e2e-user + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + - name: Run Maestro E2E tests run: | mkdir -p test-results bun test:e2e:ios --device "$SIMULATOR_UDID" --format junit --output test-results/maestro-results.xml env: - TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + TEST_EMAIL: ${{ env.TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} TRIP_NAME: E2E-Trip-${{ github.run_id }} PACK_NAME: E2E-Pack-${{ github.run_id }} - APP_ID: com.andrewbierman.packrat + # EAS e2e profile builds the preview variant, whose bundle id is + # suffixed with ".preview" via app.config.ts. + APP_ID: com.andrewbierman.packrat.preview + # xcuitest driver boot on cold GH runners can exceed the 180s + # default. Give it 10 minutes before declaring a timeout. + MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000" - name: Upload test results if: always() @@ -208,8 +257,8 @@ jobs: - name: Shutdown simulator if: always() run: | - xcrun simctl shutdown "$SIMULATOR_UDID" || true - xcrun simctl delete "$SIMULATOR_UDID" || true + xcrun simctl shutdown "${SIMULATOR_UDID:-}" || true + xcrun simctl delete "${SIMULATOR_UDID:-}" || true android-e2e: name: Android E2E Tests @@ -219,10 +268,55 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: + # The E2E user is upserted into the dev DB by the seed step below, + # so both email and password are driven entirely by repo secrets. TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} steps: + - name: Verify E2E secrets are configured + run: | + missing=() + [ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") + [ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") + [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required E2E secrets missing: ${missing[*]}" + echo "::error::Set them via: gh secret set --repo PackRat-AI/PackRat" + exit 1 + fi + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + + - name: Free disk space on runner + # Gradle builds of this RN app fail with OOM / no-space on stock + # ubuntu-latest. Prune large preinstalled toolchains we don't use. + # Keep the Android SDK/NDK — the Gradle build links against them. + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache/CodeQL \ + "$AGENT_TOOLSDIRECTORY/Ruby" "$AGENT_TOOLSDIRECTORY/PyPy" || true + sudo apt-get -y autoremove --purge '^llvm-.*' '^mongodb-.*' \ + '^mysql-.*' 'azure-cli' 'google-cloud-cli' 'google-chrome-stable' \ + 'firefox' 'powershell' 2>/dev/null || true + sudo apt-get -y autoremove || true + sudo apt-get -y clean || true + docker image prune -af || true + df -h + + - name: Configure swap for Gradle + # R8/ProGuard during :app:packageRelease regularly OOMs on 16GB + # runners without swap. Add a generous swap file. ubuntu-latest + # ships with /swapfile already active, so use a distinct path + # and dd (fallocate errors "Text file busy" on the live one). + run: | + sudo swapoff -a || true + sudo rm -f /mnt/swapfile-ci + sudo dd if=/dev/zero of=/mnt/swapfile-ci bs=1M count=10240 status=none + sudo chmod 600 /mnt/swapfile-ci + sudo mkswap /mnt/swapfile-ci + sudo swapon /mnt/swapfile-ci + free -h + - name: Checkout repository uses: actions/checkout@v6 @@ -232,10 +326,18 @@ jobs: bun-version: latest cache: true + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: | + node-modules-${{ runner.os }}- + - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Setup Expo uses: expo/expo-github-action@v8 @@ -307,7 +409,7 @@ jobs: - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2.34.0 + uses: reactivecircus/android-emulator-runner@v2.37.0 with: api-level: 34 target: google_apis @@ -319,6 +421,13 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." + - name: Seed E2E test user in dev DB + run: bun run --filter @packrat/api db:seed:e2e-user + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + - name: Run Maestro E2E tests on Android emulator uses: reactivecircus/android-emulator-runner@v2.37.0 with: @@ -337,19 +446,13 @@ jobs: adb shell pm disable-user com.android.launcher3 || true adb shell pm disable-user com.google.android.apps.nexuslauncher || true adb install apps/expo/build/PackRat.apk - cp .maestro/config.yaml .maestro/config.yaml.bak - cp .maestro/config-android.yaml .maestro/config.yaml - maestro test \ - --format junit \ - --output test-results/maestro-results.xml \ - .maestro/ - mv .maestro/config.yaml.bak .maestro/config.yaml + bash .github/scripts/e2e.sh android --format junit --output test-results/maestro-results.xml env: - TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + TEST_EMAIL: ${{ env.TEST_EMAIL }} TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} TRIP_NAME: E2E-Trip-${{ github.run_id }} PACK_NAME: E2E-Pack-${{ github.run_id }} - APP_ID: com.packratai.mobile + APP_ID: com.packratai.mobile.preview - name: Upload test results if: always() diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 3de2a2a9ad..427c04dd38 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -44,7 +44,7 @@ jobs: - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Determine target environment id: env diff --git a/.github/workflows/sync-guides-r2.yml b/.github/workflows/sync-guides-r2.yml index a04404334c..3535a5d3a4 100644 --- a/.github/workflows/sync-guides-r2.yml +++ b/.github/workflows/sync-guides-r2.yml @@ -39,7 +39,7 @@ jobs: echo "PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile timeout-minutes: 5 - name: Sync guides to R2 bucket diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 86d528bb3a..4572bb8a28 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -55,7 +55,7 @@ jobs: - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Run API unit tests run: bun run --cwd packages/api test:unit:coverage @@ -85,7 +85,7 @@ jobs: - name: Install dependencies env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} - run: bun install + run: bun install --frozen-lockfile - name: Run Expo unit tests run: bun run --cwd apps/expo test:coverage diff --git a/.gitignore b/.gitignore index 9f94169602..eb3f471249 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .eslintcache .cache *.tsbuildinfo +.next # IntelliJ based IDEs .idea @@ -35,4 +36,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Claude Code .claude/settings.json +.claude/settings.local.json +.claude/worktrees/ .dev.vars + +# Git worktrees +.worktrees/ diff --git a/README.md b/README.md index 442198267c..dbb2b34341 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ So pack your bags, grab your friends, and get ready for your next adventure with > This project is still in development and may contain bugs or issues. Please use the app with caution and report any problems you encounter. Thank you for your understanding and cooperation. **Build & CI:** -![Biome Check](https://github.com/PackRat-AI/PackRat/actions/workflows/biome.yml/badge.svg) -![Check Types](https://github.com/PackRat-AI/PackRat/actions/workflows/check-types.yml/badge.svg) +![Checks](https://github.com/PackRat-AI/PackRat/actions/workflows/checks.yml/badge.svg) ![API Tests](https://github.com/PackRat-AI/PackRat/actions/workflows/api-tests.yml/badge.svg) ![Database Migrations](https://github.com/PackRat-AI/PackRat/actions/workflows/migrations.yml/badge.svg) diff --git a/apps/admin/app/catalog/page.tsx b/apps/admin/app/catalog/page.tsx new file mode 100644 index 0000000000..b4df36ddc2 --- /dev/null +++ b/apps/admin/app/catalog/page.tsx @@ -0,0 +1,13 @@ +import { PageHeader } from 'admin-app/components/page-header'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Catalog Items' }; + +export default function CatalogPage() { + return ( +
+ +

Coming soon.

+
+ ); +} diff --git a/apps/admin/app/dashboard/analytics/catalog/page.tsx b/apps/admin/app/dashboard/analytics/catalog/page.tsx new file mode 100644 index 0000000000..0b42ec495f --- /dev/null +++ b/apps/admin/app/dashboard/analytics/catalog/page.tsx @@ -0,0 +1,17 @@ +import { CatalogAnalytics } from 'admin-app/components/analytics/catalog-analytics'; +import { PageHeader } from 'admin-app/components/page-header'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Gear Catalog Analytics' }; + +export default function CatalogAnalyticsPage() { + return ( +
+ + +
+ ); +} diff --git a/apps/admin/app/dashboard/analytics/platform/page.tsx b/apps/admin/app/dashboard/analytics/platform/page.tsx new file mode 100644 index 0000000000..98d821ef38 --- /dev/null +++ b/apps/admin/app/dashboard/analytics/platform/page.tsx @@ -0,0 +1,17 @@ +import { PlatformAnalytics } from 'admin-app/components/analytics/platform-analytics'; +import { PageHeader } from 'admin-app/components/page-header'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Platform Analytics' }; + +export default function PlatformAnalyticsPage() { + return ( +
+ + +
+ ); +} diff --git a/apps/admin/app/packs/page.tsx b/apps/admin/app/packs/page.tsx new file mode 100644 index 0000000000..e0ad2b88cb --- /dev/null +++ b/apps/admin/app/packs/page.tsx @@ -0,0 +1,13 @@ +import { PageHeader } from 'admin-app/components/page-header'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Packs' }; + +export default function PacksPage() { + return ( +
+ +

Coming soon.

+
+ ); +} diff --git a/apps/admin/app/settings/page.tsx b/apps/admin/app/settings/page.tsx new file mode 100644 index 0000000000..344213263b --- /dev/null +++ b/apps/admin/app/settings/page.tsx @@ -0,0 +1,13 @@ +import { PageHeader } from 'admin-app/components/page-header'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Settings' }; + +export default function SettingsPage() { + return ( +
+ +

Coming soon.

+
+ ); +} diff --git a/apps/admin/app/users/page.tsx b/apps/admin/app/users/page.tsx new file mode 100644 index 0000000000..869d648ffa --- /dev/null +++ b/apps/admin/app/users/page.tsx @@ -0,0 +1,13 @@ +import { PageHeader } from 'admin-app/components/page-header'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { title: 'Users' }; + +export default function UsersPage() { + return ( +
+ +

Coming soon.

+
+ ); +} diff --git a/apps/admin/components/analytics/catalog-analytics.tsx b/apps/admin/components/analytics/catalog-analytics.tsx new file mode 100644 index 0000000000..73d2221f88 --- /dev/null +++ b/apps/admin/components/analytics/catalog-analytics.tsx @@ -0,0 +1,304 @@ +'use client'; + +import { Badge } from '@packrat/web-ui/components/badge'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@packrat/web-ui/components/card'; +import type { ChartConfig } from '@packrat/web-ui/components/chart'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from '@packrat/web-ui/components/chart'; +import { + useCatalogBrands, + useCatalogEmbeddings, + useCatalogEtl, + useCatalogOverview, + useCatalogPrices, +} from 'admin-app/hooks/use-catalog-analytics'; +import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts'; + +const priceConfig: ChartConfig = { + count: { label: 'Items', color: 'hsl(var(--primary))' }, +}; + +const brandConfig: ChartConfig = { + itemCount: { label: 'Items', color: 'hsl(var(--primary))' }, +}; + +const AVAIL_COLORS = [ + 'hsl(160 60% 45%)', + 'hsl(var(--primary))', + 'hsl(38 92% 50%)', + 'hsl(0 72% 51%)', + 'hsl(280 65% 60%)', + 'hsl(211 100% 65%)', +]; + +function statusBadgeVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' { + if (status === 'completed') return 'default'; + if (status === 'failed') return 'destructive'; + return 'secondary'; +} + +export function CatalogAnalytics() { + const { data: overview } = useCatalogOverview(); + const { data: brands } = useCatalogBrands(15); + const { data: prices } = useCatalogPrices(); + const { data: etl } = useCatalogEtl(15); + const { data: embeddings } = useCatalogEmbeddings(); + + const availConfig: ChartConfig = Object.fromEntries( + (overview?.availability ?? []).map((a, i) => [ + a.status ?? 'unknown', + { + label: a.status ?? 'Unknown', + color: AVAIL_COLORS[i % AVAIL_COLORS.length] ?? 'hsl(var(--primary))', + }, + ]), + ); + + return ( +
+ {/* Overview stat cards */} + {overview && ( +
+ {[ + { label: 'Total Items', value: overview.totalItems.toLocaleString() }, + { label: 'Brands', value: overview.totalBrands.toLocaleString() }, + { + label: 'Avg Price', + value: overview.avgPrice != null ? `$${overview.avgPrice.toFixed(2)}` : '—', + }, + { label: 'Added Last 30d', value: overview.addedLast30Days.toLocaleString() }, + ].map((s) => ( + + + + {s.label} + + + +
{s.value}
+
+
+ ))} +
+ )} + + {/* Price distribution + Availability */} +
+ + + Price Distribution + Catalog items across price buckets + + + {prices && prices.length > 0 ? ( + + + + + + } /> + + + + ) : ( +
+ No data available +
+ )} +
+
+ + + + Availability + Items by stock status + + + {overview?.availability && overview.availability.length > 0 ? ( + + + ({ + ...a, + status: a.status ?? 'Unknown', + }))} + dataKey="count" + nameKey="status" + cx="50%" + cy="50%" + innerRadius={60} + outerRadius={100} + paddingAngle={2} + > + {overview.availability.map((a, i) => ( + + ))} + + } /> + } /> + + + ) : ( +
+ No data available +
+ )} +
+
+
+ + {/* Top brands */} + + + Top Brands + Brands by catalog item count + + + {brands && brands.length > 0 ? ( + + + + + + } /> + + + + ) : ( +
+ No data available +
+ )} +
+
+ + {/* Embedding coverage */} + {embeddings && ( + + + Vector Embedding Coverage + Semantic search embeddings across all catalog items + + +
+ {embeddings.coveragePct}% + of items have embeddings +
+
+
+
+
+
+
{embeddings.total.toLocaleString()}
+
Total Items
+
+
+
+ {embeddings.withEmbedding.toLocaleString()} +
+
Embedded
+
+
+
+ {embeddings.pending.toLocaleString()} +
+
Pending
+
+
+ + + )} + + {/* ETL pipeline */} + {etl && ( + + + ETL Pipeline + + {etl.summary.totalRuns} total runs — {etl.summary.completed} completed,{' '} + {etl.summary.failed} failed — {etl.summary.totalItemsIngested.toLocaleString()}{' '} + items ingested + + + +
+ + + + + + + + + + + + + {etl.jobs.map((job) => ( + + + + + + + + + ))} + +
SourceStatusProcessedValidSuccess %Started
{job.source} + + {job.status} + + + {job.totalProcessed?.toLocaleString() ?? '—'} + + {job.totalValid?.toLocaleString() ?? '—'} + + {job.successRate != null ? `${job.successRate}%` : '—'} + + {new Date(job.startedAt).toLocaleDateString()} +
+
+
+
+ )} +
+ ); +} diff --git a/apps/admin/components/analytics/platform-analytics.tsx b/apps/admin/components/analytics/platform-analytics.tsx new file mode 100644 index 0000000000..814c9b84d6 --- /dev/null +++ b/apps/admin/components/analytics/platform-analytics.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@packrat/web-ui/components/card'; +import type { ChartConfig } from '@packrat/web-ui/components/chart'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from '@packrat/web-ui/components/chart'; +import { Tabs, TabsList, TabsTrigger } from '@packrat/web-ui/components/tabs'; +import { + usePlatformActivity, + usePlatformBreakdown, + usePlatformGrowth, +} from 'admin-app/hooks/use-platform-analytics'; +import { useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Line, + LineChart, + Pie, + PieChart, + XAxis, + YAxis, +} from 'recharts'; + +const growthConfig: ChartConfig = { + users: { label: 'Users', color: 'hsl(var(--primary))' }, + packs: { label: 'Packs', color: 'hsl(211 100% 65%)' }, + catalogItems: { label: 'Catalog Items', color: 'hsl(160 60% 45%)' }, +}; + +const activityConfig: ChartConfig = { + trips: { label: 'Trips', color: 'hsl(var(--primary))' }, + trailReports: { label: 'Trail Reports', color: 'hsl(38 92% 50%)' }, + posts: { label: 'Posts', color: 'hsl(280 65% 60%)' }, +}; + +const BREAKDOWN_COLORS = [ + 'hsl(var(--primary))', + 'hsl(211 100% 65%)', + 'hsl(160 60% 45%)', + 'hsl(38 92% 50%)', + 'hsl(280 65% 60%)', + 'hsl(0 72% 51%)', +]; + +type Period = 'day' | 'week' | 'month'; + +function formatPeriodLabel(v: string, period: Period) { + const d = new Date(v); + if (period === 'day') return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + if (period === 'week') return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); +} + +export function PlatformAnalytics() { + const [period, setPeriod] = useState('month'); + + const { data: growth, isLoading: growthLoading } = usePlatformGrowth(period); + const { data: activity, isLoading: activityLoading } = usePlatformActivity(period); + const { data: breakdown } = usePlatformBreakdown(); + + const breakdownConfig: ChartConfig = Object.fromEntries( + (breakdown ?? []).map((b, i) => [ + b.category, + { + label: b.category, + color: BREAKDOWN_COLORS[i % BREAKDOWN_COLORS.length] ?? 'hsl(var(--primary))', + }, + ]), + ); + + return ( +
+ {/* Period selector */} +
+ Period: + setPeriod(v as Period)}> + + Daily + Weekly + Monthly + + +
+ + {/* Growth chart */} + + + Platform Growth + New users, packs, and catalog additions over time + + + {growthLoading ? ( +
+ Loading… +
+ ) : growth && growth.length > 0 ? ( + + + + formatPeriodLabel(v, period)} + /> + + } /> + } /> + + + + + + ) : ( +
+ No data available +
+ )} +
+
+ + {/* Activity chart */} + + + User Activity + Trips planned, trail reports, and posts over time + + + {activityLoading ? ( +
+ Loading… +
+ ) : activity && activity.length > 0 ? ( + + + + formatPeriodLabel(v, period)} + /> + + } /> + } /> + + + + + + ) : ( +
+ No data available +
+ )} +
+
+ + {/* Pack category breakdown */} + {breakdown && breakdown.length > 0 && ( + + + Pack Categories + Distribution of packs by category + + + + + + {breakdown.map((entry, index) => ( + + ))} + + } /> + } /> + + + + + )} +
+ ); +} diff --git a/apps/admin/components/dashboard/dashboard-content.tsx b/apps/admin/components/dashboard/dashboard-content.tsx new file mode 100644 index 0000000000..753333ac51 --- /dev/null +++ b/apps/admin/components/dashboard/dashboard-content.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@packrat/web-ui/components/card'; +import type { ChartConfig } from '@packrat/web-ui/components/chart'; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@packrat/web-ui/components/chart'; +import { useCatalogOverview } from 'admin-app/hooks/use-catalog-analytics'; +import { usePlatformGrowth } from 'admin-app/hooks/use-platform-analytics'; +import { Box, Database, TrendingUp, Users } from 'lucide-react'; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; + +const growthChartConfig: ChartConfig = { + users: { label: 'Users', color: 'hsl(var(--primary))' }, + packs: { label: 'Packs', color: 'hsl(211 100% 65%)' }, + catalogItems: { label: 'Catalog Items', color: 'hsl(160 60% 45%)' }, +}; + +export function DashboardContent() { + const { data: overview } = useCatalogOverview(); + const { data: growth } = usePlatformGrowth('month'); + + const recentUsers = growth?.reduce((sum, p) => sum + p.users, 0) ?? 0; + const recentPacks = growth?.reduce((sum, p) => sum + p.packs, 0) ?? 0; + + const stats = [ + { + title: 'Total Catalog Items', + value: overview?.totalItems?.toLocaleString() ?? '—', + description: `${overview?.addedLast30Days ?? 0} added last 30 days`, + icon: Box, + }, + { + title: 'Brands in Catalog', + value: overview?.totalBrands?.toLocaleString() ?? '—', + description: 'Unique gear brands', + icon: Database, + }, + { + title: 'New Users (12 mo)', + value: recentUsers.toLocaleString(), + description: 'Registered in the last year', + icon: Users, + }, + { + title: 'New Packs (12 mo)', + value: recentPacks.toLocaleString(), + description: 'Packs created in the last year', + icon: TrendingUp, + }, + ]; + + return ( +
+ {/* Stat cards */} +
+ {stats.map((stat) => ( + + + {stat.title} + + + +
{stat.value}
+

{stat.description}

+
+
+ ))} +
+ + {/* Growth chart */} + + + Platform Growth + Users and packs created over the last 12 months + + + {growth && growth.length > 0 ? ( + + + + { + const d = new Date(v); + return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + }} + /> + + } /> + + + + + ) : ( +
+ No data available +
+ )} +
+
+ + {/* Availability + embedding quick stats */} + {overview && ( +
+ + + Availability Breakdown + Catalog items by stock status + + +
+ {overview.availability.slice(0, 6).map((a) => ( +
+ + {a.status ?? 'Unknown'} + + {a.count.toLocaleString()} +
+ ))} +
+
+
+ + + + Vector Embedding Coverage + Items with semantic search embeddings + + +
+ {overview.embeddingCoverage.pct}% + coverage +
+
+
+
+
+ {overview.embeddingCoverage.withEmbedding.toLocaleString()} embedded + + {( + overview.embeddingCoverage.total - overview.embeddingCoverage.withEmbedding + ).toLocaleString()}{' '} + pending + +
+ + +
+ )} +
+ ); +} diff --git a/apps/admin/components/page-header.tsx b/apps/admin/components/page-header.tsx new file mode 100644 index 0000000000..ac1074de7e --- /dev/null +++ b/apps/admin/components/page-header.tsx @@ -0,0 +1,20 @@ +import { Separator } from '@packrat/web-ui/components/separator'; +import { SidebarTrigger } from '@packrat/web-ui/components/sidebar'; + +interface PageHeaderProps { + title: string; + description?: string; +} + +export function PageHeader({ title, description }: PageHeaderProps) { + return ( +
+ + +
+

{title}

+ {description &&

{description}

} +
+
+ ); +} diff --git a/apps/admin/config/nav.ts b/apps/admin/config/nav.ts index 1697616d85..c763cd9acf 100644 --- a/apps/admin/config/nav.ts +++ b/apps/admin/config/nav.ts @@ -1,4 +1,12 @@ -import { Backpack, LayoutDashboard, type LucideIcon, Package, Users } from 'lucide-react'; +import { + Activity, + Backpack, + BarChart2, + LayoutDashboard, + type LucideIcon, + Package, + Users, +} from 'lucide-react'; export interface NavItem { title: string; @@ -28,4 +36,14 @@ export const navItems: NavItem[] = [ href: '/dashboard/catalog', icon: Package, }, + { + title: 'Platform Analytics', + href: '/dashboard/analytics/platform', + icon: Activity, + }, + { + title: 'Gear Catalog Analytics', + href: '/dashboard/analytics/catalog', + icon: BarChart2, + }, ]; diff --git a/apps/admin/hooks/use-catalog-analytics.ts b/apps/admin/hooks/use-catalog-analytics.ts new file mode 100644 index 0000000000..ca262c7adf --- /dev/null +++ b/apps/admin/hooks/use-catalog-analytics.ts @@ -0,0 +1,45 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { + getCatalogBrands, + getCatalogEmbeddings, + getCatalogEtl, + getCatalogOverview, + getCatalogPrices, +} from 'admin-app/lib/api'; + +export function useCatalogOverview() { + return useQuery({ + queryKey: ['catalog', 'overview'], + queryFn: () => getCatalogOverview(), + }); +} + +export function useCatalogBrands(limit = 20) { + return useQuery({ + queryKey: ['catalog', 'brands', limit], + queryFn: () => getCatalogBrands(limit), + }); +} + +export function useCatalogPrices() { + return useQuery({ + queryKey: ['catalog', 'prices'], + queryFn: () => getCatalogPrices(), + }); +} + +export function useCatalogEtl(limit = 20) { + return useQuery({ + queryKey: ['catalog', 'etl', limit], + queryFn: () => getCatalogEtl(limit), + }); +} + +export function useCatalogEmbeddings() { + return useQuery({ + queryKey: ['catalog', 'embeddings'], + queryFn: () => getCatalogEmbeddings(), + }); +} diff --git a/apps/admin/hooks/use-platform-analytics.ts b/apps/admin/hooks/use-platform-analytics.ts new file mode 100644 index 0000000000..0f529f46d0 --- /dev/null +++ b/apps/admin/hooks/use-platform-analytics.ts @@ -0,0 +1,25 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { getPlatformActivity, getPlatformBreakdown, getPlatformGrowth } from 'admin-app/lib/api'; + +export function usePlatformGrowth(period: string) { + return useQuery({ + queryKey: ['platform', 'growth', period], + queryFn: () => getPlatformGrowth(period), + }); +} + +export function usePlatformActivity(period: string) { + return useQuery({ + queryKey: ['platform', 'activity', period], + queryFn: () => getPlatformActivity(period), + }); +} + +export function usePlatformBreakdown() { + return useQuery({ + queryKey: ['platform', 'breakdown'], + queryFn: () => getPlatformBreakdown(), + }); +} diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index 04a325c3de..78f9373bdd 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -154,3 +154,91 @@ export function updateCatalogItem( body: JSON.stringify(data), }); } + +// ─── Analytics — Platform ───────────────────────────────────────────────────── + +export type GrowthPoint = { period: string; users: number; packs: number; catalogItems: number }; +export type ActivityPoint = { period: string; trips: number; trailReports: number; posts: number }; +export type BreakdownItem = { category: string; count: number }; + +export function getPlatformGrowth(period: string): Promise { + return adminFetch(`/analytics/platform/growth?period=${period}`); +} + +export function getPlatformActivity(period: string): Promise { + return adminFetch(`/analytics/platform/activity?period=${period}`); +} + +export function getPlatformBreakdown(): Promise { + return adminFetch('/analytics/platform/breakdown'); +} + +// ─── Analytics — Catalog ───────────────────────────────────────────────────── + +export type CatalogOverview = { + totalItems: number; + totalBrands: number; + avgPrice: number | null; + minPrice: number | null; + maxPrice: number | null; + embeddingCoverage: { total: number; withEmbedding: number; pct: number }; + availability: { status: string | null; count: number }[]; + addedLast30Days: number; +}; + +export type BrandRow = { + brand: string; + itemCount: number; + avgPrice: number | null; + minPrice: number | null; + maxPrice: number | null; + avgRating: number | null; +}; + +export type PriceBucket = { bucket: string; count: number }; + +export type EtlJob = { + id: string; + status: 'running' | 'completed' | 'failed'; + source: string; + filename: string; + scraperRevision: string; + startedAt: string; + completedAt: string | null; + totalProcessed: number | null; + totalValid: number | null; + totalInvalid: number | null; + successRate: number | null; +}; + +export type EtlResponse = { + jobs: EtlJob[]; + summary: { totalRuns: number; completed: number; failed: number; totalItemsIngested: number }; +}; + +export type EmbeddingStats = { + total: number; + withEmbedding: number; + pending: number; + coveragePct: number; +}; + +export function getCatalogOverview(): Promise { + return adminFetch('/analytics/catalog/overview'); +} + +export function getCatalogBrands(limit = 20): Promise { + return adminFetch(`/analytics/catalog/brands?limit=${limit}`); +} + +export function getCatalogPrices(): Promise { + return adminFetch('/analytics/catalog/prices'); +} + +export function getCatalogEtl(limit = 20): Promise { + return adminFetch(`/analytics/catalog/etl?limit=${limit}`); +} + +export function getCatalogEmbeddings(): Promise { + return adminFetch('/analytics/catalog/embeddings'); +} diff --git a/apps/admin/package.json b/apps/admin/package.json index f15786a02f..ecd94f564b 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,11 +1,12 @@ { "name": "packrat-admin-app", - "version": "2.0.20", + "version": "2.0.21", "private": true, "scripts": { "build": "next build", "clean": "bunx rimraf node_modules .next out", "dev": "next dev --port 3002", + "doctor:react": "bunx react-doctor", "lint": "next lint", "start": "next start" }, @@ -31,6 +32,7 @@ "next-themes": "^0.4.6", "react": "catalog:", "react-dom": "catalog:", + "recharts": "3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^2.5.5", "zod": "catalog:" diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 271ecde1a9..363d4a96c3 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -37,7 +37,7 @@ export default (): ExpoConfig => { name: getAppName(), slug: 'packrat', - version: '2.0.20', + version: '2.0.21', scheme: 'packrat', web: { bundler: 'metro', diff --git a/apps/expo/app/(app)/(tabs)/(home)/index.tsx b/apps/expo/app/(app)/(tabs)/(home)/index.tsx index d747a476d6..3ed08e2da3 100644 --- a/apps/expo/app/(app)/(tabs)/(home)/index.tsx +++ b/apps/expo/app/(app)/(tabs)/(home)/index.tsx @@ -10,7 +10,7 @@ import { } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; import TabScreen from 'expo-app/components/TabScreen'; -import { featureFlags } from 'expo-app/config'; +import { appConfig, featureFlags } from 'expo-app/config'; import { AIChatTile } from 'expo-app/features/ai/components/AIChatTile'; import { ReportedContentTile } from 'expo-app/features/ai/components/ReportedContentTile'; import { AIPacksTile } from 'expo-app/features/ai-packs/components/AIPacksTile'; @@ -154,6 +154,7 @@ const tileInfo = { }; type TileName = keyof typeof tileInfo; +const DASHBOARD_GAP_PREFIX = appConfig.dashboard.gapPrefix; export default function DashboardScreen() { const [searchValue, setSearchValue] = useState(''); @@ -185,32 +186,29 @@ export default function DashboardScreen() { ); const dashboardLayout = useRef([ - 'current-pack', - 'recent-packs', - 'season-suggestions', - 'gap 1', - 'ask-packrat-ai', - 'reported-ai-content', - 'ai-packs', - 'gap 1.5', - 'pack-stats', - 'weight-analysis', - 'pack-categories', - ...(featureFlags.enableTrips || featureFlags.enableTrailConditions ? ['gap 2'] : []), - ...(featureFlags.enableTrips ? ['upcoming-trips'] : []), - ...(featureFlags.enableTrailConditions ? ['trail-conditions'] : []), - 'gap 2.5', - 'weather', - ...(featureFlags.enableTrips ? ['weather-alerts'] : []), - 'gap 3', - 'gear-inventory', - ...(featureFlags.enableShoppingList ? ['shopping-list'] : []), - ...(featureFlags.enableSharedPacks ? ['shared-packs'] : []), - ...(featureFlags.enablePackTemplates ? ['pack-templates'] : []), - ...(featureFlags.enableFeed ? ['feed'] : []), - 'gap 4', - 'guides', - ...(featureFlags.enableWildlifeIdentification ? ['wildlife'] : []), + ...appConfig.dashboard.layout.base, + ...(featureFlags.enableTrips || featureFlags.enableTrailConditions + ? [appConfig.dashboard.layout.conditional.tripsOrTrailSpacer] + : []), + ...(featureFlags.enableTrips ? [appConfig.dashboard.layout.conditional.trips] : []), + ...(featureFlags.enableTrailConditions + ? [appConfig.dashboard.layout.conditional.trailConditions] + : []), + ...appConfig.dashboard.layout.weatherSection, + ...(featureFlags.enableTrips ? [appConfig.dashboard.layout.conditional.weatherAlerts] : []), + ...appConfig.dashboard.layout.gearSection, + ...(featureFlags.enableShoppingList + ? [appConfig.dashboard.layout.conditional.shoppingList] + : []), + ...(featureFlags.enableSharedPacks ? [appConfig.dashboard.layout.conditional.sharedPacks] : []), + ...(featureFlags.enablePackTemplates + ? [appConfig.dashboard.layout.conditional.packTemplates] + : []), + ...(featureFlags.enableFeed ? [appConfig.dashboard.layout.conditional.feed] : []), + ...appConfig.dashboard.layout.footerSection, + ...(featureFlags.enableWildlifeIdentification + ? [appConfig.dashboard.layout.conditional.wildlife] + : []), ]).current; const filteredTiles = useMemo(() => { @@ -218,7 +216,7 @@ export default function DashboardScreen() { const searchLower = searchValue.toLowerCase(); return dashboardLayout.filter((item) => { - if (!item.startsWith('gap')) { + if (!item.startsWith(DASHBOARD_GAP_PREFIX)) { const info = localizedTileInfo[item as TileName]; return ( info.title.toLowerCase().includes(searchLower) || @@ -237,7 +235,7 @@ export default function DashboardScreen() { ref: asNonNullableRef(searchBarRef), iosHideWhenScrolling: true, onChangeText: setSearchValue, - placeholder: 'Search...', + placeholder: appConfig.dashboard.strings.searchPlaceholder, content: searchValue ? ( { assertIsString(item); - if (!item.startsWith('gap')) { + if (!item.startsWith(DASHBOARD_GAP_PREFIX)) { const Component = tileInfo[item as TileName].component; return ( filteredTiles.length > 0 ? ( - {filteredTiles.length} {filteredTiles.length === 1 ? 'result' : 'results'} + {filteredTiles.length}{' '} + {filteredTiles.length === 1 + ? appConfig.dashboard.strings.resultSingular + : appConfig.dashboard.strings.resultPlural} ) : null } @@ -308,7 +309,7 @@ export default function DashboardScreen() { function renderDashboardItem(info: ListRenderItemInfo) { const item = info.item as string; - if (item.startsWith('gap')) { + if (item.startsWith(DASHBOARD_GAP_PREFIX)) { return ; } diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index d42e116926..85db0be3b0 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -1,3 +1,4 @@ +import { clientEnvs } from '@packrat/env/expo-client'; import type { AlertMethods } from '@packrat/ui/nativewindui'; import { ActivityIndicator, @@ -16,7 +17,6 @@ import { import AsyncStorage from '@react-native-async-storage/async-storage'; import { Icon } from 'expo-app/components/Icon'; import TabScreen from 'expo-app/components/TabScreen'; -import { clientEnvs } from 'expo-app/env/clientEnvs'; import { withAuthWall } from 'expo-app/features/auth/hocs'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index c8e7d774f3..8413733bcd 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -1,11 +1,12 @@ import { type UIMessage, useChat } from '@ai-sdk/react'; +import { clientEnvs } from '@packrat/env/expo-client'; import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; import { DefaultChatTransport, type TextUIPart } from 'ai'; import * as Burnt from 'burnt'; import { fetch as expoFetch } from 'expo/fetch'; import { AiChatHeader } from 'expo-app/components/ai-chatHeader'; import { Icon } from 'expo-app/components/Icon'; -import { clientEnvs } from 'expo-app/env/clientEnvs'; +import { featureFlags } from 'expo-app/config'; import { aiModeAtom, localModelStatusAtom } from 'expo-app/features/ai/atoms/aiModeAtoms'; import { clearChatMessages, @@ -109,7 +110,7 @@ export default function AIChat() { // Kick off model init check on mount (prepares already-downloaded models) React.useEffect(() => { - initLocalModel(); + if (featureFlags.enableLocalAI) initLocalModel(); }, []); // Keep a ref for context body values so the transport closure stays fresh @@ -122,7 +123,7 @@ export default function AIChat() { const tools = React.useMemo(() => createLocalTools(), []); const transport = React.useMemo(() => { - if (aiMode === 'local' && isLocalReady) { + if (featureFlags.enableLocalAI && aiMode === 'local' && isLocalReady) { const model = getLocalModel(); if (model) { return new CustomChatTransport(model, tools); @@ -201,7 +202,7 @@ export default function AIChat() { const messageText = text || input; // Guard: local mode but model not ready - if (aiMode === 'local' && modelStatus !== 'ready') { + if (featureFlags.enableLocalAI && aiMode === 'local' && modelStatus !== 'ready') { Burnt.toast({ title: t('ai.modelNotReady'), preset: 'error', diff --git a/apps/expo/app/(app)/trip/location-search.tsx b/apps/expo/app/(app)/trip/location-search.tsx index 3590114816..60e3bf69e7 100644 --- a/apps/expo/app/(app)/trip/location-search.tsx +++ b/apps/expo/app/(app)/trip/location-search.tsx @@ -1,3 +1,4 @@ +import { clientEnvs } from '@packrat/env/expo-client'; import { ActivityIndicator, Button, SearchInput } from '@packrat/ui/nativewindui'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import Constants from 'expo-constants'; @@ -9,7 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useTripLocation } from '../../../features/trips/store/tripLocationStore'; const GOOGLE_MAPS_API_KEY = - Constants.expoConfig?.extra?.googleMapsApiKey || process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY; + Constants.expoConfig?.extra?.googleMapsApiKey || clientEnvs.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY; export default function LocationSearchScreen() { const router = useRouter(); diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index baf476114e..af81e293c3 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -6,6 +6,7 @@ import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import '../global.css'; +import { clientEnvs } from '@packrat/env/expo-client'; import { Alert, type AlertMethods } from '@packrat-ai/nativewindui'; import * as Sentry from '@sentry/react-native'; import { userStore } from 'expo-app/features/auth/store'; @@ -15,7 +16,7 @@ import { NAV_THEME } from 'expo-app/theme'; import { useRef } from 'react'; Sentry.init({ - dsn: process.env.EXPO_PUBLIC_SENTRY_DSN, + dsn: clientEnvs.EXPO_PUBLIC_SENTRY_DSN, // Adds more context data to events (IP address, cookies, user, etc.) // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ sendDefaultPii: true, diff --git a/apps/expo/config.ts b/apps/expo/config.ts index 98097ffec6..6d6c7f1200 100644 --- a/apps/expo/config.ts +++ b/apps/expo/config.ts @@ -1,11 +1,4 @@ -export const featureFlags = { - enableOAuth: true, - enableTrips: true, - enablePackInsights: false, - enableShoppingList: false, - enableSharedPacks: false, - enablePackTemplates: true, - enableTrailConditions: false, - enableFeed: false, - enableWildlifeIdentification: false, -}; +import { APP_CONFIG } from '@packrat/config/config'; + +export const appConfig = APP_CONFIG; +export const featureFlags = appConfig.featureFlags; diff --git a/apps/expo/eas.json b/apps/expo/eas.json index 9d11ea3d25..38175655af 100644 --- a/apps/expo/eas.json +++ b/apps/expo/eas.json @@ -25,8 +25,10 @@ "buildType": "apk", "gradleCommand": ":app:assembleRelease -x lintVitalAnalyzeRelease", "env": { - "GRADLE_OPTS": "-Xmx6144m -XX:MaxMetaspaceSize=1536m", - "JAVA_OPTS": "-Xmx6144m" + "GRADLE_OPTS": "-Xmx7168m -XX:MaxMetaspaceSize=1536m -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=2", + "JAVA_OPTS": "-Xmx7168m", + "_JAVA_OPTIONS": "-Xmx7168m", + "ORG_GRADLE_PROJECT_org_gradle_jvmargs": "-Xmx7168m -XX:MaxMetaspaceSize=1536m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8" } } }, diff --git a/apps/expo/env/clientEnvs.ts b/apps/expo/env/clientEnvs.ts deleted file mode 100644 index b18b5bf067..0000000000 --- a/apps/expo/env/clientEnvs.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const clientEnvSchema = z.object({ - NODE_ENV: z.enum(['development', 'production']).default('production'), - EXPO_PUBLIC_API_URL: z.string().url(), - EXPO_PUBLIC_R2_PUBLIC_URL: z.string().url(), - EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: z.string(), - EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: z.string(), -}); - -const processEnv = { - NODE_ENV: process.env.NODE_ENV, - EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL, - EXPO_PUBLIC_R2_PUBLIC_URL: process.env.EXPO_PUBLIC_R2_PUBLIC_URL, - EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, - EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, -}; - -export const clientEnvs = clientEnvSchema.parse(processEnv); diff --git a/apps/expo/env/serverEnvs.ts b/apps/expo/env/serverEnvs.ts deleted file mode 100644 index cab9f90260..0000000000 --- a/apps/expo/env/serverEnvs.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -export const serverEnvs = z.object({ - OPENAI_API_KEY: z.string(), -}); - -const processEnv = { - OPENAI_API_KEY: process.env.OPENAI_API_KEY, -}; - -export const serverEnv = serverEnvs.parse(processEnv); diff --git a/apps/expo/features/ai/components/AIModeSelector.tsx b/apps/expo/features/ai/components/AIModeSelector.tsx index c0cefadfcb..5a4b819280 100644 --- a/apps/expo/features/ai/components/AIModeSelector.tsx +++ b/apps/expo/features/ai/components/AIModeSelector.tsx @@ -1,6 +1,7 @@ import type { BottomSheetModal } from '@gorhom/bottom-sheet'; import { ActivityIndicator, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; +import { featureFlags } from 'expo-app/config'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useAtomValue } from 'jotai'; @@ -16,6 +17,8 @@ export function AIModeSelector() { const modelStatus = useAtomValue(localModelStatusAtom); const sheetRef = React.useRef(null); + if (!featureFlags.enableLocalAI) return null; + const isDownloading = modelStatus === 'downloading' || modelStatus === 'preparing' || modelStatus === 'checking'; const label = mode === 'cloud' ? t('ai.cloud') : t('ai.local'); diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index c529ff13e1..446812ee29 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -1,3 +1,4 @@ +import { clientEnvs } from '@packrat/env/expo-client'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin, @@ -5,7 +6,6 @@ import { statusCodes, } from '@react-native-google-signin/google-signin'; import type { AxiosError } from 'axios'; -import { clientEnvs } from 'expo-app/env/clientEnvs'; import { userStore } from 'expo-app/features/auth/store'; import axiosInstance from 'expo-app/lib/api/client'; import { t } from 'expo-app/lib/i18n'; diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 70e6a214bc..5ec2ba1473 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -1,6 +1,6 @@ +import { clientEnvs } from '@packrat/env/expo-client'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; -import { clientEnvs } from 'expo-app/env/clientEnvs'; import { router } from 'expo-router'; import Storage from 'expo-sqlite/kv-store'; import { useEffect, useState } from 'react'; diff --git a/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx b/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx index 178f3a1f8e..c9929a0171 100644 --- a/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx +++ b/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx @@ -1,7 +1,7 @@ -import { Button, LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; +import { Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { usePathname, useRouter } from 'expo-router'; +import { Stack, usePathname, useRouter } from 'expo-router'; import { View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -12,7 +12,7 @@ export function CatalogItemsAuthWall() { return ( - + diff --git a/apps/expo/features/feed/utils/__tests__/feedUtils.test.ts b/apps/expo/features/feed/utils/__tests__/feedUtils.test.ts index 3ddc667200..bcc717f084 100644 --- a/apps/expo/features/feed/utils/__tests__/feedUtils.test.ts +++ b/apps/expo/features/feed/utils/__tests__/feedUtils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; // Mock clientEnvs before importing the module under test so that // buildPostImageUrl has a deterministic CDN base URL. -vi.mock('expo-app/env/clientEnvs', () => ({ +vi.mock('@packrat/env/expo-client', () => ({ clientEnvs: { EXPO_PUBLIC_R2_PUBLIC_URL: 'https://cdn.example.com', }, diff --git a/apps/expo/features/feed/utils/index.ts b/apps/expo/features/feed/utils/index.ts index 5dc4718409..fdfca54b8c 100644 --- a/apps/expo/features/feed/utils/index.ts +++ b/apps/expo/features/feed/utils/index.ts @@ -1,4 +1,4 @@ -import { clientEnvs } from 'expo-app/env/clientEnvs'; +import { clientEnvs } from '@packrat/env/expo-client'; import { getRelativeTime } from 'expo-app/lib/utils/getRelativeTime'; import type { Comment, Post } from '../types'; diff --git a/apps/expo/features/trips/screens/TripDetailScreen.tsx b/apps/expo/features/trips/screens/TripDetailScreen.tsx index 41552c0e1f..b32f528ec3 100644 --- a/apps/expo/features/trips/screens/TripDetailScreen.tsx +++ b/apps/expo/features/trips/screens/TripDetailScreen.tsx @@ -3,7 +3,6 @@ import { ActivityIndicator, Button, Card, Text } from '@packrat/ui/nativewindui' import { Icon } from 'expo-app/components/Icon'; import { featureFlags } from 'expo-app/config'; import { SubmitConditionReportForm } from 'expo-app/features/trail-conditions/components/SubmitConditionReportForm'; -import { useLocations } from 'expo-app/features/weather/hooks'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useLocalSearchParams, useRouter } from 'expo-router'; @@ -21,9 +20,6 @@ export function TripDetailScreen() { const { colors } = useColorScheme(); const { t } = useTranslation(); - const { locationsState } = useLocations(); - - const locations = locationsState.state === 'hasData' ? locationsState.data : []; const [showConditionReport, setShowConditionReport] = useState(false); const trip = useTripDetailsFromStore(id as string) as Trip; diff --git a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx index 8702779940..e7ebcf90ec 100644 --- a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx +++ b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx @@ -1,6 +1,11 @@ import { Icon } from 'expo-app/components/Icon'; import { WeatherForecast } from 'expo-app/features/weather/components/WeatherForecast'; import { getWeatherBackgroundColors } from 'expo-app/features/weather/lib/weatherService'; +import type { + WeatherApiForecastResponse, + ForecastDay as WeatherForecastDay, + HourWeather as WeatherHourlyForecast, +} from 'expo-app/features/weather/types'; import axiosInstance from 'expo-app/lib/api/client'; import { LinearGradient } from 'expo-linear-gradient'; import { router, Stack, useLocalSearchParams } from 'expo-router'; @@ -20,7 +25,7 @@ export default function TripWeatherDetailsScreen() { const latitude = Number(lat); const longitude = Number(lon); - const [weather, setWeather] = useState(null); + const [weather, setWeather] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [gradientColors, setGradientColors] = useState<[string, string, ...string[]]>([ @@ -85,15 +90,18 @@ export default function TripWeatherDetailsScreen() { const location = weather.location; const current = weather.current; + const todayForecast = weather.forecast.forecastday[0]; + + const hourlyForecast = weather?.forecast?.forecastday?.[0]?.hour?.map( + (h: WeatherHourlyForecast) => ({ + time: `${new Date(h.time).getHours()}:00`, + temp: Math.round(h.temp_c), + weatherCode: h.condition?.code ?? 1000, + isDay: h.is_day, + }), + ); - const hourlyForecast = weather?.forecast?.forecastday?.[0]?.hour?.map((h: any) => ({ - time: String(new Date(h.time).getHours()) + ':00', - temp: Math.round(h.temp_c), - weatherCode: h.condition?.code ?? 1000, - isDay: h.is_day, - })); - - const dailyForecast = weather?.forecast?.forecastday?.map((fd: any) => ({ + const dailyForecast = weather?.forecast?.forecastday?.map((fd: WeatherForecastDay) => ({ day: new Intl.DateTimeFormat('en', { weekday: 'short' }).format(new Date(fd.date)), icon: 'weather-partly-cloudy', low: Math.round(fd.day.mintemp_c), @@ -124,8 +132,9 @@ export default function TripWeatherDetailsScreen() { {current.condition.text} - H:{weather.forecast.forecastday[0].day.maxtemp_c}° L: - {weather.forecast.forecastday[0].day.mintemp_c}° + {todayForecast + ? `H:${todayForecast.day.maxtemp_c}° L:${todayForecast.day.mintemp_c}°` + : 'H:— L:—'} ({ +vi.mock('@packrat/env/expo-client', () => ({ clientEnvs: { EXPO_PUBLIC_R2_PUBLIC_URL: 'https://cdn.example.com', }, diff --git a/apps/expo/lib/utils/__tests__/buildPackTemplateItemImageUrl.test.ts b/apps/expo/lib/utils/__tests__/buildPackTemplateItemImageUrl.test.ts index 6881b78887..29a71925d2 100644 --- a/apps/expo/lib/utils/__tests__/buildPackTemplateItemImageUrl.test.ts +++ b/apps/expo/lib/utils/__tests__/buildPackTemplateItemImageUrl.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; // Mock clientEnvs before importing -vi.mock('expo-app/env/clientEnvs', () => ({ +vi.mock('@packrat/env/expo-client', () => ({ clientEnvs: { EXPO_PUBLIC_R2_PUBLIC_URL: 'https://cdn.packrat.com', }, diff --git a/apps/expo/lib/utils/buildImageUrl.ts b/apps/expo/lib/utils/buildImageUrl.ts index acdab4bbbf..014b951e7c 100644 --- a/apps/expo/lib/utils/buildImageUrl.ts +++ b/apps/expo/lib/utils/buildImageUrl.ts @@ -1,4 +1,4 @@ -import { clientEnvs } from 'expo-app/env/clientEnvs'; +import { clientEnvs } from '@packrat/env/expo-client'; import type { PackTemplateItem } from 'expo-app/features/pack-templates'; import type { PackItem } from 'expo-app/features/packs'; diff --git a/apps/expo/lib/utils/buildPackTemplateItemImageUrl.ts b/apps/expo/lib/utils/buildPackTemplateItemImageUrl.ts index 1f5c23f69f..d1639f3cd3 100644 --- a/apps/expo/lib/utils/buildPackTemplateItemImageUrl.ts +++ b/apps/expo/lib/utils/buildPackTemplateItemImageUrl.ts @@ -1,4 +1,4 @@ -import { clientEnvs } from 'expo-app/env/clientEnvs'; +import { clientEnvs } from '@packrat/env/expo-client'; export function buildPackTemplateItemImageUrl(image?: string | null): string | null { if (!image) return null; diff --git a/apps/expo/package.json b/apps/expo/package.json index 9667912272..11cc2cab1d 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -1,6 +1,6 @@ { "name": "packrat-expo-app", - "version": "2.0.20", + "version": "2.0.21", "private": true, "main": "expo-router/entry", "scripts": { @@ -26,6 +26,7 @@ "check-types": "tsc --noEmit", "check-types-watch": "tsc --noEmit --watch", "clean": "bunx rimraf .expo node_modules ios android", + "doctor:react": "bunx expo-doctor", "format": "biome format --write", "ios": "APP_VARIANT=development expo run:ios", "lint": "biome check --write", @@ -50,6 +51,8 @@ "@gorhom/bottom-sheet": "^5.1.2", "@legendapp/state": "^3.0.0-beta.30", "@packrat-ai/nativewindui": "^2.0.2", + "@packrat/config": "workspace:*", + "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", @@ -83,13 +86,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "expo": "^54.0.0", + "expo": "^54.0.33", "expo-apple-authentication": "~8.0.8", "expo-blur": "~15.0.8", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-dev-client": "~6.0.20", "expo-file-system": "~19.0.21", + "expo-font": "~14.0.9", "expo-glass-effect": "~0.1.9", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", @@ -134,6 +138,7 @@ "react-native-screens": "~4.16.0", "react-native-uitextview": "^1.1.4", "react-native-web": "^0.21.0", + "react-native-worklets": "0.5.1", "rn-icon-mapper": "^0.0.1", "tailwind-merge": "^2.5.5", "use-debounce": "^10.0.5", diff --git a/apps/guides/app/api/dev/generate-batch/route.ts b/apps/guides/app/api/dev/generate-batch/route.ts index 40998bd2e5..d4b8dc08b5 100644 --- a/apps/guides/app/api/dev/generate-batch/route.ts +++ b/apps/guides/app/api/dev/generate-batch/route.ts @@ -1,10 +1,11 @@ +import { guideEnv } from '@packrat/env/next'; import { generatePosts } from 'guides-app/scripts/generate-content'; import { NextResponse } from 'next/server'; export const dynamic = 'force-static'; // Ensure this only works in development -const isDevelopment = process.env.NODE_ENV === 'development'; +const isDevelopment = guideEnv.NODE_ENV === 'development'; export async function POST(request: Request) { // Block in production diff --git a/apps/guides/app/api/dev/generate-post/route.ts b/apps/guides/app/api/dev/generate-post/route.ts index 2ac68e0d17..a07a951dcd 100644 --- a/apps/guides/app/api/dev/generate-post/route.ts +++ b/apps/guides/app/api/dev/generate-post/route.ts @@ -1,10 +1,11 @@ +import { guideEnv } from '@packrat/env/next'; import { generatePost } from 'guides-app/scripts/generate-content'; import { NextResponse } from 'next/server'; export const dynamic = 'force-static'; // Ensure this only works in development -const isDevelopment = process.env.NODE_ENV === 'development'; +const isDevelopment = guideEnv.NODE_ENV === 'development'; export async function POST(request: Request) { // Block in production diff --git a/apps/guides/app/dev/generate/page.tsx b/apps/guides/app/dev/generate/page.tsx index 0a0873ce18..5d3c411b12 100644 --- a/apps/guides/app/dev/generate/page.tsx +++ b/apps/guides/app/dev/generate/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { guideEnv } from '@packrat/env/next'; import { Badge } from '@packrat/web-ui/components/badge'; import { Button } from '@packrat/web-ui/components/button'; import { @@ -31,7 +32,7 @@ import { FileText, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { useState } from 'react'; // This ensures the page only works in development -const _isDevelopment = process.env.NODE_ENV === 'development'; +const _isDevelopment = guideEnv.NODE_ENV === 'development'; // Types type ContentCategory = diff --git a/apps/guides/lib/enhanceGuideContent.ts b/apps/guides/lib/enhanceGuideContent.ts index 4b5a145d69..e8d90c47bd 100644 --- a/apps/guides/lib/enhanceGuideContent.ts +++ b/apps/guides/lib/enhanceGuideContent.ts @@ -1,4 +1,5 @@ import { openai } from '@ai-sdk/openai'; +import { guideEnv } from '@packrat/env/next'; import { generateText, tool } from 'ai'; import axios from 'axios'; import { z } from 'zod'; @@ -44,7 +45,7 @@ export async function enhanceGuideContent( temperature = 0.3, maxSearchResults = 5, apiBaseUrl = 'http://localhost:8787', - apiKey = process.env.PACKRAT_API_KEY, + apiKey = guideEnv.PACKRAT_API_KEY, } = options; if (!apiKey) { diff --git a/apps/guides/package.json b/apps/guides/package.json index a518b1260d..4ec7437c17 100644 --- a/apps/guides/package.json +++ b/apps/guides/package.json @@ -1,6 +1,6 @@ { "name": "packrat-guides-app", - "version": "2.0.20", + "version": "2.0.21", "private": true, "scripts": { "build": "bun run build-content && next build", @@ -8,6 +8,7 @@ "clean": "bunx rimraf .next node_modules out", "demo-enhancement": "bun run scripts/demo-enhancement.ts", "dev": "next dev", + "doctor:react": "bunx react-doctor", "enhance-content": "bun run scripts/enhance-content.ts", "lint": "next lint", "start": "next start", @@ -19,6 +20,7 @@ "@ai-sdk/openai": "^2.0.11", "@hookform/resolvers": "^3.10.0", "@packrat/api": "workspace:*", + "@packrat/env": "workspace:*", "@packrat/web-ui": "workspace:*", "@radix-ui/react-accordion": "catalog:", "@radix-ui/react-alert-dialog": "catalog:", diff --git a/apps/guides/scripts/update-authors.ts b/apps/guides/scripts/update-authors.ts index 157c62d880..91e7021108 100644 --- a/apps/guides/scripts/update-authors.ts +++ b/apps/guides/scripts/update-authors.ts @@ -22,11 +22,11 @@ function getUniqueAuthors(): string[] { const posts = getAllPosts(); const authors = new Set(); - posts.forEach((post) => { + for (const post of posts) { if (post.author && post.author !== 'Unknown') { authors.add(post.author); } - }); + } return Array.from(authors).sort(); } @@ -84,9 +84,9 @@ function showAuthorDistribution(): void { const posts = getAllPosts(); const distribution: Record = {}; - posts.forEach((post) => { + for (const post of posts) { distribution[post.author] = (distribution[post.author] || 0) + 1; - }); + } console.log(chalk.blue('\n=== Current Author Distribution ===')); console.log(chalk.blue(`Total posts: ${posts.length}`)); @@ -97,10 +97,10 @@ function showAuthorDistribution(): void { ); console.log(chalk.blue('\nAuthor Distribution:')); - sortedAuthors.forEach(([author, count]) => { + for (const [author, count] of sortedAuthors) { const percentage = ((count / posts.length) * 100).toFixed(1); console.log(`${chalk.green('✓')} ${author}: ${count} posts (${percentage}%)`); - }); + } } // List all available authors @@ -110,9 +110,9 @@ function listAvailableAuthors(): void { console.log(chalk.blue('\n=== Available Authors ===')); console.log(chalk.blue(`Found ${authors.length} authors in existing content:`)); - authors.forEach((author, index) => { + for (const [index, author] of authors.entries()) { console.log(` ${index + 1}. ${author}`); - }); + } } // Update a specific post by slug @@ -123,9 +123,9 @@ function updatePostBySlug(slug: string, newAuthor: string): void { if (!post) { console.error(chalk.red(`Error: Post with slug "${slug}" not found.`)); console.log(chalk.yellow('Available slugs:')); - posts.forEach((p) => { + for (const p of posts) { console.log(` - ${p.slug}`); - }); + } return; } @@ -151,9 +151,9 @@ function updatePostsByTitle(searchTitle: string, newAuthor: string): void { if (matchingPosts.length > 1) { console.log(chalk.yellow(`Found ${matchingPosts.length} matching posts:`)); - matchingPosts.forEach((post, index) => { + for (const [index, post] of matchingPosts.entries()) { console.log(` ${index + 1}. "${post.title}" (${post.slug}) - Author: ${post.author}`); - }); + } console.log(chalk.yellow('Please be more specific or use slug instead.')); return; } @@ -172,20 +172,20 @@ function updatePostsByAuthor(currentAuthor: string, newAuthor: string): void { console.error(chalk.red(`No posts found with author: "${currentAuthor}"`)); console.log(chalk.yellow('Available authors:')); const authors = getUniqueAuthors(); - authors.forEach((author) => { + for (const author of authors) { console.log(` - ${author}`); - }); + } return; } console.log(chalk.blue(`Found ${matchingPosts.length} posts by "${currentAuthor}"`)); let updatedCount = 0; - matchingPosts.forEach((post) => { + for (const post of matchingPosts) { if (updatePostAuthor(post, newAuthor)) { updatedCount++; } - }); + } console.log( chalk.green(`✓ Updated ${updatedCount} posts from "${currentAuthor}" to "${newAuthor}"`), @@ -198,9 +198,9 @@ function rebalanceAuthors(): void { // Use the top 6 most active authors for rebalancing (similar to original logic) const distribution: Record = {}; - posts.forEach((post) => { + for (const post of posts) { distribution[post.author] = (distribution[post.author] || 0) + 1; - }); + } const mainAuthors = Object.entries(distribution) .sort(([, countA], [, countB]) => countB - countA) @@ -211,35 +211,35 @@ function rebalanceAuthors(): void { console.log(chalk.blue(`\n=== Rebalancing Authors ===`)); console.log(chalk.blue(`Using top ${mainAuthors.length} authors for rebalancing:`)); - mainAuthors.forEach((author) => { + for (const author of mainAuthors) { console.log(` - ${author}`); - }); + } console.log(chalk.blue(`Target per author: ~${targetPerAuthor} posts`)); // Get current distribution for main authors only const currentDistribution: Record = {}; - mainAuthors.forEach((author) => { + for (const author of mainAuthors) { currentDistribution[author] = posts.filter((post) => post.author === author); - }); + } // Pre-compute author counts for efficient tracking during reassignment const authorCounts: Record = {}; - mainAuthors.forEach((author) => { + for (const author of mainAuthors) { authorCounts[author] = currentDistribution[author]?.length || 0; - }); + } // Find posts that need reassignment (authored by non-main authors or over-represented authors) const postsToReassign: PostMetadata[] = []; // Add posts from non-main authors - posts.forEach((post) => { + for (const post of posts) { if (!mainAuthors.includes(post.author)) { postsToReassign.push(post); } - }); + } // Add excess posts from over-represented authors - mainAuthors.forEach((author) => { + for (const author of mainAuthors) { const authorPosts = currentDistribution[author] || []; if (authorPosts.length > targetPerAuthor) { const excess = authorPosts.slice(targetPerAuthor); @@ -247,7 +247,7 @@ function rebalanceAuthors(): void { // Update the count to reflect posts that will be reassigned authorCounts[author] = targetPerAuthor; } - }); + } if (postsToReassign.length === 0) { console.log(chalk.green('Authors are already well balanced!')); @@ -258,7 +258,7 @@ function rebalanceAuthors(): void { // Assign posts to authors with the fewest posts (O(n log n) instead of O(n²)) let updatedCount = 0; - postsToReassign.forEach((post) => { + for (const post of postsToReassign) { // Find author with least posts using pre-computed counts const authorWithLeast = mainAuthors.reduce((min, author) => (authorCounts[author] ?? 0) < (authorCounts[min] ?? 0) ? author : min, @@ -270,7 +270,7 @@ function rebalanceAuthors(): void { updatedCount++; } } - }); + } console.log(chalk.green(`✓ Rebalanced ${updatedCount} posts`)); console.log(chalk.blue('\nNew distribution:')); @@ -370,9 +370,9 @@ if (isMainModule()) { console.log(chalk.yellow(`No posts found matching: "${args[1]}"`)); } else { console.log(chalk.blue(`Found ${matchingPosts.length} posts matching "${args[1]}":`)); - matchingPosts.forEach((post, index) => { + for (const [index, post] of matchingPosts.entries()) { console.log(` ${index + 1}. "${post.title}" (${post.slug}) - Author: ${post.author}`); - }); + } } break; } @@ -399,14 +399,14 @@ if (isMainModule()) { // Export functions for programmatic use export { + findPostsByTitle, getAllPosts, + getUniqueAuthors, + type PostMetadata, + rebalanceAuthors, showAuthorDistribution, updatePostAuthor, updatePostBySlug, - updatePostsByTitle, updatePostsByAuthor, - rebalanceAuthors, - findPostsByTitle, - getUniqueAuthors, - type PostMetadata, + updatePostsByTitle, }; diff --git a/apps/landing/package.json b/apps/landing/package.json index 9bdccf029c..a900a1a408 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -1,11 +1,12 @@ { "name": "packrat-landing-app", - "version": "2.0.20", + "version": "2.0.21", "private": true, "scripts": { "build": "next build", "clean": "bunx rimraf node_modules .next out", "dev": "next dev", + "doctor:react": "bunx react-doctor", "lint": "next lint", "start": "next start" }, diff --git a/biome.json b/biome.json index 7c5cbdfded..70c6443124 100644 --- a/biome.json +++ b/biome.json @@ -69,6 +69,7 @@ "packages/api/test/setup.ts", "apps/expo/utils/weight.ts", "packages/api/src/utils/weight.ts", + "packages/mcp/src/index.ts", "scripts/lint/**", "scripts/format/**", ".github/scripts/**", diff --git a/bun.lock b/bun.lock index cf13a5f095..76fa357243 100644 --- a/bun.lock +++ b/bun.lock @@ -13,12 +13,13 @@ "fs-extra": "^11.3.0", "glob": "^11.0.3", "lefthook": "^1.11.14", + "semver": "catalog:", "sort-package-json": "^3.6.1", }, }, "apps/admin": { "name": "packrat-admin-app", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@packrat/web-ui": "workspace:*", "@radix-ui/react-alert-dialog": "catalog:", @@ -57,7 +58,7 @@ }, "apps/expo": { "name": "packrat-expo-app", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@ai-sdk/react": "^2.0.11", "@expo/react-native-action-sheet": "^4.1.1", @@ -65,6 +66,8 @@ "@gorhom/bottom-sheet": "^5.1.2", "@legendapp/state": "^3.0.0-beta.30", "@packrat-ai/nativewindui": "^2.0.2", + "@packrat/config": "workspace:*", + "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", @@ -98,13 +101,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "expo": "^54.0.0", + "expo": "^54.0.33", "expo-apple-authentication": "~8.0.8", "expo-blur": "~15.0.8", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-dev-client": "~6.0.20", "expo-file-system": "~19.0.21", + "expo-font": "~14.0.9", "expo-glass-effect": "~0.1.9", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", @@ -149,6 +153,7 @@ "react-native-screens": "~4.16.0", "react-native-uitextview": "^1.1.4", "react-native-web": "^0.21.0", + "react-native-worklets": "0.5.1", "rn-icon-mapper": "^0.0.1", "tailwind-merge": "^2.5.5", "use-debounce": "^10.0.5", @@ -176,11 +181,12 @@ }, "apps/guides": { "name": "packrat-guides-app", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@ai-sdk/openai": "^2.0.11", "@hookform/resolvers": "^3.10.0", "@packrat/api": "workspace:*", + "@packrat/env": "workspace:*", "@packrat/web-ui": "workspace:*", "@radix-ui/react-accordion": "catalog:", "@radix-ui/react-alert-dialog": "catalog:", @@ -258,7 +264,7 @@ }, "apps/landing": { "name": "packrat-landing-app", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@emotion/is-prop-valid": "^1.3.1", "@hookform/resolvers": "^3.10.0", @@ -323,9 +329,10 @@ }, "packages/analytics": { "name": "@packrat/analytics", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@duckdb/node-api": "1.5.0-r.1", + "@packrat/env": "workspace:*", "consola": "^3.4.2", "magic-regexp": "^0.11.0", "radash": "catalog:", @@ -338,6 +345,7 @@ }, "packages/api": { "name": "@packrat/api", + "version": "2.0.21", "dependencies": { "@ai-sdk/google": "^2.0.62", "@ai-sdk/openai": "^2.0.11", @@ -350,6 +358,7 @@ "@hono/zod-validator": "^0.7.6", "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", + "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", "@scalar/hono-api-reference": "^0.10.7", "@types/nodemailer": "^6.4.17", @@ -388,33 +397,61 @@ "wrangler": "^4.21.2", }, }, + "packages/api-client": { + "name": "@packrat/api-client", + "version": "2.0.21", + "devDependencies": { + "typescript": "catalog:", + }, + }, + "packages/checks": { + "name": "@packrat/checks", + "version": "2.0.21", + }, "packages/cli": { "name": "@packrat/cli", - "version": "2.0.20", + "version": "2.0.21", + "bin": { + "packrat": "./src/index.ts", + }, "dependencies": { "@duckdb/node-api": "1.5.0-r.1", "@packrat/analytics": "workspace:*", + "@packrat/env": "workspace:*", "chalk": "catalog:", "citty": "^0.2.1", "cli-table3": "^0.6.5", "consola": "^3.4.2", + "zod": "catalog:", }, "devDependencies": { "@types/bun": "latest", }, }, + "packages/config": { + "name": "@packrat/config", + "version": "2.0.21", + }, + "packages/env": { + "name": "@packrat/env", + "version": "2.0.21", + "dependencies": { + "zod": "catalog:", + }, + }, "packages/guards": { "name": "@packrat/guards", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "radash": "catalog:", }, }, "packages/mcp": { "name": "@packrat/mcp", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", + "@packrat/api-client": "workspace:*", "agents": "^0.11.0", "zod": "catalog:", }, @@ -429,14 +466,14 @@ }, "packages/ui": { "name": "@packrat/ui", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@packrat-ai/nativewindui": "^2.0.2", }, }, "packages/web-ui": { "name": "@packrat/web-ui", - "version": "2.0.20", + "version": "2.0.21", "dependencies": { "@packrat/guards": "workspace:*", "@radix-ui/react-accordion": "catalog:", @@ -476,7 +513,6 @@ "react-day-picker": "9.14.0", "react-hook-form": "^7.58.1", "react-resizable-panels": "^4.10.0", - "recharts": "3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", @@ -485,13 +521,18 @@ "devDependencies": { "@types/react": "~19.1.10", "react": "catalog:", + "recharts": "3.8.1", "tailwindcss": "catalog:", "typescript": "catalog:", }, "peerDependencies": { "react": "catalog:", + "recharts": "3.8.1", "tailwindcss": "catalog:", }, + "optionalPeers": [ + "recharts", + ], }, }, "trustedDependencies": [ @@ -531,6 +572,7 @@ "radash": "^12.1.1", "react": "19.1.0", "react-dom": "19.1.0", + "semver": "^7.7.4", "tailwindcss": "^3.4.17", "typescript": "~5.9.2", "zod": "^3.24.2", @@ -1208,8 +1250,16 @@ "@packrat/api": ["@packrat/api@workspace:packages/api"], + "@packrat/api-client": ["@packrat/api-client@workspace:packages/api-client"], + + "@packrat/checks": ["@packrat/checks@workspace:packages/checks"], + "@packrat/cli": ["@packrat/cli@workspace:packages/cli"], + "@packrat/config": ["@packrat/config@workspace:packages/config"], + + "@packrat/env": ["@packrat/env@workspace:packages/env"], + "@packrat/guards": ["@packrat/guards@workspace:packages/guards"], "@packrat/mcp": ["@packrat/mcp@workspace:packages/mcp"], @@ -3478,7 +3528,7 @@ "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], - "react-native-worklets": ["react-native-worklets@0.7.4", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-NYOdM1MwBb3n+AtMqy1tFy3Mn8DliQtd8sbzAVRf9Gc+uvQ0zRfxN7dS8ZzoyX7t6cyQL5THuGhlnX+iFlQTag=="], + "react-native-worklets": ["react-native-worklets@0.5.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-classes": "^7.0.0-0", "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", "@babel/plugin-transform-optional-chaining": "^7.0.0-0", "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", "@babel/plugin-transform-template-literals": "^7.0.0-0", "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", "semver": "7.7.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0", "react": "*", "react-native": "*" } }, "sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], @@ -3616,7 +3666,7 @@ "sembear": ["sembear@0.7.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-XyLTEich2D02FODCkfdto3mB9DetWPLuTzr4tvoofe9SvyM27h4nQSbV3+iVcYQz94AFyKtqBv5pcZbj3k2hdA=="], - "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -4708,7 +4758,7 @@ "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], - "react-native-worklets/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "react-native-worklets/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "read-pkg/path-type": ["path-type@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "pify": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg=="], diff --git a/copilot-instructions.md b/copilot-instructions.md index 1b8c348cbe..640eb5a7b7 100644 --- a/copilot-instructions.md +++ b/copilot-instructions.md @@ -228,9 +228,8 @@ packages/ api/ Cloudflare Workers API (Hono, Drizzle, OpenAPI) ui/ Shared UI components (requires GitHub auth) .github/ - workflows/ CI/CD pipelines + workflows/ CI/CD pipelines (incl. copilot-setup-steps.yml) scripts/ Build and configuration scripts - copilot-setup-steps.yml Copilot coding agent environment setup ``` ### Key Files @@ -244,13 +243,13 @@ packages/ | `apps/expo/app.config.js` | Expo configuration | | `biome.json` | Formatting and linting rules | | `lefthook.yml` | Git hooks (auto-runs `bun format` on pre-push) | +| `.github/workflows/copilot-setup-steps.yml` | Copilot cloud agent environment bootstrap | ## CI/CD Workflows | Workflow | Trigger | Purpose | |----------|---------|---------| -| `biome.yml` | Pull Requests | Code formatting and linting | -| `check-types.yml` | Pull Requests | TypeScript type checking | +| `checks.yml` | Pull Requests + Manual | Lint/format checks, type checking, and optional manual Biome autofix | | `api-tests.yml` | Push to main/dev + PRs | Vitest API tests | | `migrations.yml` | Push to main/dev | Database schema migrations | | `sync-guides-r2.yml` | Push to dev + Manual | Sync guides content to Cloudflare R2 | @@ -260,6 +259,16 @@ packages/ - Cloudflare API tokens for deployment - Database URL and API keys (see `.env.example`) +**Copilot Cloud Agent Environment:** + +The Copilot coding agent uses `.github/workflows/copilot-setup-steps.yml` to bootstrap its environment. The setup: +1. Validates `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN` is present (fail-fast, actionable error) +2. Pins Node.js 24 and Bun 1.3.10 to match `package.json` engine constraints +3. Installs all workspace dependencies via `bun install --frozen-lockfile` +4. Smoke-checks Biome, TypeScript, and Wrangler CLIs + +To enable Copilot, add `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN` (PAT with `read:packages`) as a secret in **Settings → Environments → copilot → Secrets**. + ## Validation Always validate changes manually before committing: diff --git a/docs/plans/2026-04-15-001-refactor-hono-rpc-foundation-plan.md b/docs/plans/2026-04-15-001-refactor-hono-rpc-foundation-plan.md new file mode 100644 index 0000000000..6b050e0971 --- /dev/null +++ b/docs/plans/2026-04-15-001-refactor-hono-rpc-foundation-plan.md @@ -0,0 +1,417 @@ +--- +title: refactor: Establish Hono RPC foundation with standalone api-client workspace +type: refactor +status: active +date: 2026-04-15 +deepened: 2026-04-15 +--- + +# refactor: Establish Hono RPC foundation with standalone api-client workspace + +## Overview + +Establish a repo-wide Hono RPC foundation that exports a stable typed API surface from `packages/api`, consumes that surface from a new standalone `packages/api-client` workspace, and proves end-to-end request/response inference from an Expo consumer without yet replacing the entire `axios` and raw `fetch` call graph. + +This first milestone is intentionally narrow. It should leave the repo in a state where type-safe RPC is demonstrably viable and ergonomically consumable. Broad consumer migration comes after that proof. + +## Problem Frame + +The current server already uses Hono and `OpenAPIHono`, but the client side is split across: + +- `apps/expo/lib/api/client.ts` with an `axios` instance, token attachment, refresh retry queue, and re-auth trigger behavior +- many feature-level `axiosInstance.*` calls across Expo +- several raw `fetch` calls in auth flows and a few other feature paths + +That transport layer has no compile-time coupling to the server routes. Renaming a route, changing query/body shapes, or changing response types can break consumers silently until runtime. + +Upstream Hono RPC guidance is strict about a few things that materially affect this repo: + +- monorepo RPC typing requires `"strict": true` in both client and server tsconfigs +- larger apps should export the type of a chained route registration, not an ad hoc mutable router object +- global `onError()` responses are not inferred automatically by `hc` +- IDE/type-instantiation cost increases with route volume, so the exported type surface must be deliberate + +The goal of this plan is to make the type-safe path real and provable before touching the full consumer migration. + +## Requirements Trace + +- R1. Introduce a standalone `api-client` workspace that owns Hono RPC client creation and shared client-side typing concerns. +- R2. Export a stable RPC-safe server type from `packages/api` that survives nested route composition and `OpenAPIHono`. +- R3. Preserve the current Expo auth behavior model for future migration: bearer token attachment, refresh retry queue, and `needsReauthAtom` signaling. +- R4. Prove that a consumer in `apps/expo` gets strongly inferred request and response types from the Hono server without hand-written DTO duplication. +- R5. Keep the first milestone narrow: no repo-wide replacement of `axios` and raw `fetch` yet. +- R6. Bump Hono-related dependencies as part of the foundation work, but do so as one compatibility-checked family rather than piecemeal. + +## Scope Boundaries + +- No full replacement of every `axiosInstance` call in `apps/expo`. +- No removal of `apps/expo/lib/api/client.ts` in this milestone. +- No broad rewrite of auth flows that currently use raw `fetch`. +- No generated OpenAPI client or schema-codegen pipeline. +- No changes to `apps/landing` or `apps/guides` consumers unless needed for shared package compatibility. + +### Deferred to Separate Tasks + +- Full migration of Expo feature modules from `axios` to the new RPC client. +- Migration of auth entry points (`login`, `register`, social auth, password reset) to typed RPC calls. +- Cleanup/removal of legacy transport helpers after all consumers have moved. +- Any further client adoption in non-Expo apps. + +## Context & Research + +### Relevant Code and Patterns + +- `packages/api/src/index.ts` mounts the API at `app.route('/api', routes)` and owns global middleware plus `onError()`. +- `packages/api/src/routes/index.ts` composes public and protected subrouters with imperative `route()` calls on mutable `OpenAPIHono` instances. +- Domain router index files such as `packages/api/src/routes/packs/index.ts` and `packages/api/src/routes/trips/index.ts` follow the same imperative composition pattern. +- Route handlers already declare request/response contracts with `createRoute(...)` plus `app.openapi(...)`, which is the right source of truth for RPC typing. +- `apps/expo/lib/api/client.ts` centralizes auth header injection, token refresh, retry queueing, and re-auth fallback. +- `apps/expo/features/auth/README.md` documents the required non-blocking re-auth behavior and should remain the behavioral contract for any future RPC transport. +- `apps/expo/tsconfig.json` and `packages/api/tsconfig.json` already have `"strict": true`, which satisfies the Hono monorepo RPC prerequisite. + +### Institutional Learnings + +- No `docs/solutions/` knowledge base exists in this repo today, so there are no prior institutional learnings to reuse for this migration. + +### External References + +- Hono RPC guide: `https://hono.dev/docs/guides/rpc` +- Hono Best Practices, especially larger apps and RPC chaining: `https://hono.dev/docs/guides/best-practices` +- Hono full docs export: `https://hono.dev/llms-full.txt` +- `@hono/zod-openapi` README, RPC mode: `https://github.com/honojs/middleware/tree/main/packages/zod-openapi#rpc-mode` +- Hono releases page for current family updates: `https://github.com/honojs/hono/releases` + +## Key Technical Decisions + +- Use Hono RPC directly, not OpenAPI code generation. + Rationale: the server is already authored in Hono and `OpenAPIHono`; direct `hc` preserves source-of-truth typing with less moving machinery. + +- Introduce `packages/api-client` as the only shared client-entry workspace. + Rationale: this isolates Hono RPC setup, keeps app consumers thin, and avoids scattering `hc` configuration across Expo features. + +- Export a dedicated RPC-safe route type from chained route composition. + Rationale: upstream Hono guidance for larger applications is explicit that chained `route()` composition is the safe path for inferred `AppType`. + +- Keep auth/retry behavior in an adapter layer outside the core RPC package. + Rationale: token storage, refresh queues, and re-auth atoms are Expo-specific concerns; `packages/api-client` should stay portable while `apps/expo` owns runtime auth wiring. + +- Add compile-time proof before runtime migration. + Rationale: the user’s success criterion is type safety first. If inference is weak or brittle, broad migration should stop. + +- Treat global 500 responses as an explicit typed concern. + Rationale: Hono does not infer `onError()` responses automatically, so the client package must either apply `ApplyGlobalResponse` or deliberately leave those responses untyped. This plan chooses explicit typing. + +- Bump Hono-family dependencies together. + Rationale: `hono`, `@hono/zod-openapi`, `@hono/zod-validator`, and related middleware have been evolving quickly. Moving them in one audited pass reduces mixed-version type breakage. + +## Open Questions + +### Resolved During Planning + +- Should the first pass also migrate all consumers? + Resolution: no. This milestone ends once typed RPC is exported, consumable, and proven from Expo. + +- Should auth behavior move into the shared `api-client` package? + Resolution: no. The shared package should expose a transport seam; Expo keeps ownership of token persistence, refresh queueing, and re-auth signaling. + +- Should this work happen in an isolated branch/worktree? + Resolution: yes. Use a dedicated worktree/branch for this milestone, with a concise branch name such as `refactor/hono-rpc-foundation`. + +### Deferred to Implementation + +- Exact target versions for the Hono-family dependency bump. + Why deferred: the implementation should resolve the newest mutually compatible set at install time rather than hard-coding a guessed version in the plan. + +- Whether a single exported `AppType` is ergonomically acceptable for IDE performance, or whether the client package should also export route-slice types. + Why deferred: the correct split depends on actual type-check and editor performance after the first proof harness lands. + +- Whether Expo should instantiate one singleton RPC client or multiple specialized clients. + Why deferred: the type foundation should be built first; final ergonomics can be tuned during consumer migration. + +## Output Structure + +```text +docs/ + plans/ + 2026-04-15-001-refactor-hono-rpc-foundation-plan.md +packages/ + api-client/ + package.json + tsconfig.json + src/ + index.ts + client.ts + responses.ts + types.ts + test/ + rpc-types.test.ts +apps/ + expo/ + lib/ + api/ + rpcClient.ts + rpcTransport.ts + test/ + rpc-client-proof.test.ts +``` + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart LR + A[packages/api route modules] --> B[Chained RPC-safe route export] + B --> C[AppType or RpcAppType] + C --> D[packages/api-client createApiClient] + D --> E[ApplyGlobalResponse and typed helpers] + E --> F[apps/expo rpc transport adapter] + F --> G[Typed Expo proof usage] + + H[Expo token storage and refresh queue] --> F + I[Legacy axios/raw fetch consumers] -.deferred migration.- G +``` + +The important separation is: + +- `packages/api` owns the typed route graph +- `packages/api-client` owns RPC client construction and shared response typing +- `apps/expo` owns runtime auth behavior + +## Implementation Units + +- [ ] **Unit 1: Create isolated workspace scaffold and dependency alignment** + +**Goal:** Add the `packages/api-client` workspace, wire it into repo-level TypeScript/package resolution, and align Hono-family dependencies before any route typing work begins. + +**Requirements:** R1, R6 + +**Dependencies:** None + +**Files:** +- Modify: `package.json` +- Modify: `tsconfig.json` +- Modify: `packages/api/package.json` +- Modify: `apps/expo/package.json` +- Modify: `bun.lock` +- Create: `packages/api-client/package.json` +- Create: `packages/api-client/tsconfig.json` + +**Approach:** +- Add a new workspace package named `@packrat/api-client`. +- Add repo-level path aliases or package consumption paths so Expo can import it as a normal workspace package rather than by source-relative hacks. +- Bump Hono-family dependencies in a single pass after checking upstream compatibility notes and changelogs. +- Keep `strict` enabled in the new package tsconfig from day one. + +**Patterns to follow:** +- `packages/ui/package.json` for workspace package naming and placement +- `apps/expo/tsconfig.json` and `packages/api/tsconfig.json` for package-local strict TypeScript configuration + +**Test scenarios:** +- Happy path: workspace install resolves `@packrat/api-client` from Expo and API packages without manual symlinks or path hacks. +- Edge case: root type-check still resolves existing `@packrat/api/*` and `@packrat/ui/*` imports after adding the new alias/package. +- Error path: incompatible Hono-family version bumps fail fast during install or type-check rather than surfacing later as ambiguous route typing. + +**Verification:** +- The monorepo installs cleanly and recognizes `@packrat/api-client` as a workspace package. +- Type-check configuration for the new package is strict and reachable from Expo. + +- [ ] **Unit 2: Export an RPC-safe server type surface from chained route composition** + +**Goal:** Refactor server route composition so the repo exports a stable type suitable for Hono RPC across nested `OpenAPIHono` routers. + +**Requirements:** R2, R4 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `packages/api/src/index.ts` +- Modify: `packages/api/src/routes/index.ts` +- Modify: `packages/api/src/routes/packs/index.ts` +- Modify: `packages/api/src/routes/trips/index.ts` +- Modify: `packages/api/src/routes/catalog/index.ts` +- Modify: `packages/api/src/routes/guides/index.ts` +- Modify: `packages/api/src/routes/packTemplates/index.ts` +- Test: `packages/api/test/health.test.ts` +- Test: `packages/api/test/auth.test.ts` +- Test: `packages/api/test/packs.test.ts` + +**Approach:** +- Capture the return value of top-level `route()` composition in a dedicated exported variable such as `rpcRoutes` or `apiRoutes`, instead of relying only on mutable router instances. +- Preserve the worker entry shape in `packages/api/src/index.ts` so deployment behavior does not change. +- Export the route type from the routed graph rather than from the worker default export object. +- Audit route aggregators that currently register subrouters imperatively and update the boundaries that must contribute to the exported RPC type. +- If needed, add an explicit typed global error wrapper using Hono client utilities so the shared client can account for the global `500` JSON shape from `onError()`. + +**Execution note:** Start with a compile-only proof of the exported route type before any Expo integration. + +**Patterns to follow:** +- Existing domain router index files in `packages/api/src/routes/*/index.ts` +- Hono Best Practices for larger applications and RPC chaining + +**Test scenarios:** +- Happy path: the exported app type exposes mounted paths such as `/api/packs`, `/api/trips`, `/api/auth`, and nested parameterized routes to a client type. +- Edge case: route composition through nested router index files still preserves parameter, query, and body inference. +- Error path: explicit `404`, `401`, and `400` route responses remain inferable as typed JSON unions where those routes already declare them. +- Integration: the runtime worker export still responds through `fetch` exactly as before after the route-type export refactor. + +**Verification:** +- A type import from `packages/api` can be fed to `hc<...>()` without losing mounted route paths. +- Existing API tests continue to pass against the unchanged runtime entry point. + +- [ ] **Unit 3: Build the standalone shared RPC client package** + +**Goal:** Implement `packages/api-client` as the canonical location for Hono client creation, response typing helpers, and transport hooks. + +**Requirements:** R1, R2, R4 + +**Dependencies:** Unit 2 + +**Files:** +- Create: `packages/api-client/src/index.ts` +- Create: `packages/api-client/src/client.ts` +- Create: `packages/api-client/src/responses.ts` +- Create: `packages/api-client/src/types.ts` +- Test: `packages/api-client/test/rpc-types.test.ts` + +**Approach:** +- Import the server `AppType` from `@packrat/api` and expose a small factory such as `createApiClient(...)`. +- Use `hc(baseUrl, options)` as the core client primitive. +- Centralize any Hono-specific typing helpers here, including `InferResponseType`, `InferRequestType`, and `ApplyGlobalResponse` if the server keeps a global typed error contract. +- Keep the shared package runtime-agnostic by accepting injected `fetch`, headers, and request init options instead of directly touching Expo storage or Jotai state. +- Export a narrow public surface so future consumers do not depend on raw `hc` internals everywhere. + +**Patterns to follow:** +- `apps/expo/lib/api/client.ts` as the behavioral reference for what the transport layer eventually needs to support +- Hono RPC guide for `hc`, status-specific response typing, custom `fetch`, and custom query serialization + +**Test scenarios:** +- Happy path: a typed client factory exposes correct method names and request shapes for representative routes such as packs list, weather lookup, and auth refresh. +- Edge case: route helpers correctly infer parameterized routes and query-bearing routes. +- Error path: a route with explicit `404` or `401` responses produces a typed union rather than `unknown`. +- Integration: global error typing is present for the shared `500` JSON contract if `ApplyGlobalResponse` is adopted. + +**Verification:** +- The shared package can be imported without Expo-only dependencies. +- Type tests fail if a server route shape changes in a way the client package no longer matches. + +- [ ] **Unit 4: Add an Expo RPC transport adapter that preserves current auth semantics** + +**Goal:** Instantiate the shared RPC client inside Expo using a custom transport that preserves bearer token injection, refresh retries, and re-auth signaling. + +**Requirements:** R3, R4 + +**Dependencies:** Unit 3 + +**Files:** +- Create: `apps/expo/lib/api/rpcTransport.ts` +- Create: `apps/expo/lib/api/rpcClient.ts` +- Modify: `apps/expo/features/auth/README.md` +- Test: `apps/expo/test/rpc-client-proof.test.ts` + +**Approach:** +- Build a custom `fetch` adapter for Hono `hc` that mirrors the current `axios` interceptor contract: + attach bearer token, catch `401`, queue concurrent retries during refresh, update stored tokens on success, and trigger `needsReauthAtom` on hard failure. +- Keep legacy `apps/expo/lib/api/client.ts` in place during this milestone; the new adapter is additive. +- Limit runtime wiring to one shared client instantiation point in Expo so future consumer migration does not recreate auth logic per feature. +- Document which auth flows remain outside RPC for now. + +**Execution note:** Add characterization coverage for refresh queue behavior before trying to replace any existing runtime callers. + +**Patterns to follow:** +- `apps/expo/lib/api/client.ts` +- `apps/expo/features/auth/README.md` +- Hono RPC custom `fetch` guidance + +**Test scenarios:** +- Happy path: authenticated requests include the current bearer token and succeed through the RPC client. +- Edge case: multiple concurrent `401` responses trigger one refresh flow and replay queued requests once tokens are updated. +- Error path: refresh failure sets `needsReauthAtom` and rejects queued callers consistently. +- Integration: the Expo-specific transport can be passed into the shared `createApiClient(...)` factory without weakening route inference. + +**Verification:** +- Expo has a single typed RPC client entry point ready for future adoption. +- The new transport reproduces the existing auth-refresh semantics in tests or characterization coverage. + +- [ ] **Unit 5: Prove end-to-end type safety from an Expo consumer without broad migration** + +**Goal:** Add a consumer-side proof harness that demonstrates the type-safe path is real before the repo commits to migrating all callers. + +**Requirements:** R4, R5 + +**Dependencies:** Unit 4 + +**Files:** +- Modify: `apps/expo/package.json` +- Test: `apps/expo/test/rpc-client-proof.test.ts` +- Test: `packages/api-client/test/rpc-types.test.ts` + +**Approach:** +- Write compile-focused proof usage against a small but representative set of endpoints: + at least one query route, one param route, one JSON body route, and one route with explicit non-200 responses. +- Use type assertions that fail loudly if request or response inference regresses. +- Keep proof usage out of feature screens for now; the success criterion is compile-time confidence, not visible product change. +- Produce a migration inventory from existing `axiosInstance` and raw `fetch` usage to seed the next task, but do not convert those callers in this milestone. + +**Patterns to follow:** +- Existing Vitest setup in `apps/expo/vitest.config.ts` +- Existing repo pattern of colocated test files under `test/` or `__tests__/` + +**Test scenarios:** +- Happy path: Expo proof code gets fully typed request inputs and `res.json()` output for representative routes. +- Edge case: route params and query keys autocomplete and reject invalid keys/types at compile time. +- Error path: explicit `404`, `401`, and `500` response shapes are represented in client-side narrowing logic. +- Integration: changing a server route contract breaks the proof harness at compile time without requiring a runtime failure. + +**Verification:** +- There is at least one compile-enforced Expo consumer proof showing end-to-end inference from server to client. +- The repo has a concrete migration inventory for the next phase. + +## System-Wide Impact + +- **Interaction graph:** `packages/api` route composition and `onError()` typing now become an external contract for `packages/api-client`, which in turn becomes a dependency of Expo transport code. +- **Error propagation:** route-level typed JSON errors should remain explicit; global `500` handling should be modeled centrally rather than inferred ad hoc by consumers. +- **State lifecycle risks:** token refresh queueing is the highest-risk runtime concern because it currently coordinates concurrent request replay and re-auth fallback. +- **API surface parity:** any future client consumer should go through `packages/api-client` rather than creating its own `hc` instance. +- **Integration coverage:** compile-time proof alone is insufficient for auth refresh; the adapter must also have runtime-oriented coverage for queue replay and failure signaling. +- **Unchanged invariants:** deployment entry shape in `packages/api/src/index.ts`, auth token storage keys, and the non-blocking re-auth UX contract must remain unchanged in this first milestone. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Exported route type loses mounted paths because composition remains imperative in one or more aggregators | Refactor all composition boundaries that contribute to the exported client graph and add compile-only route shape proof early | +| Hono-family dependency bump introduces subtle type regressions with `OpenAPIHono` | Bump the family together, type-check immediately, and keep the first proof harness small enough to localize failures | +| IDE performance degrades when exporting the entire route graph | Measure editor/type-check ergonomics during proof and split into route-slice exports later if needed | +| Expo auth semantics drift from existing axios behavior | Treat `apps/expo/lib/api/client.ts` plus `apps/expo/features/auth/README.md` as characterization sources and test the refresh queue explicitly | +| Shared package becomes Expo-specific and hard to reuse | Keep runtime auth logic in Expo adapter files and keep `packages/api-client` transport-agnostic | + +## Documentation / Operational Notes + +- Update `apps/expo/features/auth/README.md` to explain the coexistence period between legacy `axios` transport and the new RPC transport. +- Capture the existing `axios` and raw `fetch` call inventory during implementation so the next migration plan can batch consumers intentionally. +- Keep the work on an isolated worktree/branch because the server route typing refactor and dependency bump are both broad blast-radius changes. + +## Phased Delivery + +### Phase 1 + +- Units 1-3 +- Outcome: server exports a usable `AppType` and a shared `@packrat/api-client` package exists + +### Phase 2 + +- Units 4-5 +- Outcome: Expo can instantiate the typed client with preserved auth semantics, and type safety is proven without broad consumer migration + +## Sources & References + +- Related code: `packages/api/src/index.ts` +- Related code: `packages/api/src/routes/index.ts` +- Related code: `apps/expo/lib/api/client.ts` +- Related code: `apps/expo/features/auth/hooks/useAuthActions.ts` +- External docs: `https://hono.dev/docs/guides/rpc` +- External docs: `https://hono.dev/docs/guides/best-practices` +- External docs: `https://hono.dev/llms-full.txt` +- External docs: `https://github.com/honojs/middleware/tree/main/packages/zod-openapi#rpc-mode` +- External docs: `https://github.com/honojs/hono/releases` diff --git a/package.json b/package.json index b7cecd341b..954cdcfa2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "packrat-monorepo", - "version": "2.0.20", + "version": "2.0.21", "workspaces": [ "apps/*", "packages/*" @@ -14,7 +14,9 @@ "check:catalog": "bun scripts/lint/no-duplicate-deps.ts", "check:circular": "bun scripts/lint/no-circular-deps.ts", "check:deps": "manypkg check", + "check:magic-strings": "bun run --cwd packages/checks check:magic-strings", "check:package-json": "bun scripts/format/sort-package-json.ts --check", + "check:react-doctor": "bun scripts/lint/check-react-doctor.ts", "check-types": "tsc --noEmit", "check-types-watch": "tsc --noEmit --watch", "clean": "bun run .github/scripts/clean.ts", @@ -29,7 +31,7 @@ "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", - "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts", + "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run packages/env/scripts/no-raw-process-env.ts", "lint:strict": "biome check && bun run lint:custom", "lint-unsafe": "biome check --write --unsafe", "mcp": "bun run --cwd packages/mcp dev", diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 1333039b4c..7eba221f8d 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/analytics", - "version": "2.0.20", + "version": "2.0.21", "private": true, "type": "module", "scripts": { @@ -10,6 +10,7 @@ }, "dependencies": { "@duckdb/node-api": "1.5.0-r.1", + "@packrat/env": "workspace:*", "consola": "^3.4.2", "magic-regexp": "^0.11.0", "radash": "catalog:", diff --git a/packages/analytics/src/core/catalog-cache.ts b/packages/analytics/src/core/catalog-cache.ts index bb218d6d98..878d68083e 100644 --- a/packages/analytics/src/core/catalog-cache.ts +++ b/packages/analytics/src/core/catalog-cache.ts @@ -7,13 +7,12 @@ * which resolves to the Iceberg table via `USE packrat.default`. */ -import type { DuckDBConnection, DuckDBInstance } from '@duckdb/node-api'; +import type { DuckDBConnection } from '@duckdb/node-api'; import consola from 'consola'; import { createCatalogConnection } from './connection'; import { LocalCacheManager } from './local-cache'; export class CatalogCacheManager extends LocalCacheManager { - private catalogInstance: DuckDBInstance | null = null; private catalogConn: DuckDBConnection | null = null; constructor() { @@ -25,8 +24,7 @@ export class CatalogCacheManager extends LocalCacheManager { if (this.catalogConn) return this.catalogConn; consola.start('Connecting to R2 Data Catalog (Iceberg)...'); - const { instance, conn } = await createCatalogConnection(); - this.catalogInstance = instance; + const { conn } = await createCatalogConnection(); this.catalogConn = conn; consola.success('Connected to R2 Data Catalog'); @@ -35,7 +33,6 @@ export class CatalogCacheManager extends LocalCacheManager { override async close(): Promise { this.catalogConn = null; - this.catalogInstance = null; } override getConnection(): DuckDBConnection { diff --git a/packages/analytics/src/core/data-export.ts b/packages/analytics/src/core/data-export.ts index 7cc839c439..c521a061a3 100644 --- a/packages/analytics/src/core/data-export.ts +++ b/packages/analytics/src/core/data-export.ts @@ -10,6 +10,8 @@ import type { DuckDBConnection } from '@duckdb/node-api'; import { DBConfig, QUALITY_WEIGHTS } from './constants'; import { SQLFragments } from './query-builder'; +const FILE_EXTENSION_PATTERN = /\.\w+$/; + // ── Types ──────────────────────────────────────────────────────────────── export interface ExportOptions { @@ -204,7 +206,10 @@ export class DataExporter { strategy: dedup, }; - writeFileSync(filepath.replace(/\.\w+$/, '.summary.json'), JSON.stringify(summary, null, 2)); + writeFileSync( + filepath.replace(FILE_EXTENSION_PATTERN, '.summary.json'), + JSON.stringify(summary, null, 2), + ); return summary; } diff --git a/packages/analytics/src/core/enrichment.ts b/packages/analytics/src/core/enrichment.ts index 68001f33bb..3c6c43f6b9 100644 --- a/packages/analytics/src/core/enrichment.ts +++ b/packages/analytics/src/core/enrichment.ts @@ -32,6 +32,11 @@ import { SQLFragments } from './query-builder'; const digits = oneOrMore(digit); const wordChars = oneOrMore(wordChar); +const PRODUCT_PATH_PATTERN = /\/(product|pdp|main|hero|primary)\//; +const LIFESTYLE_PATH_PATTERN = /\/(lifestyle|action|model)\//; +const DETAIL_PATH_PATTERN = /\/(detail|zoom|swatch)\//; +const TRAILING_QUESTION_MARK_PATTERN = /\?$/; +const QUESTION_MARK_AMP_PATTERN = /\?&/; /** CDN sizing/quality query params — e.g. `?w=500`, `&quality=auto`. * Longest names listed first so alternation matches greedily. */ @@ -90,7 +95,9 @@ export function normalizeImageUrl(url: string): string { // Fresh regex reference per call to avoid lastIndex issues is unnecessary // because `.replace` on a non-sticky global regex does not depend on lastIndex. let normalized = url.replace(CDN_QUERY_PARAMS, ''); - normalized = normalized.replace(/\?$/, '').replace(/\?&/, '?'); + normalized = normalized + .replace(TRAILING_QUESTION_MARK_PATTERN, '') + .replace(QUESTION_MARK_AMP_PATTERN, '?'); normalized = normalized.replace(CDN_PATH_TRANSFORMS, ''); return normalized.trim(); } @@ -99,9 +106,9 @@ export function normalizeImageUrl(url: string): string { export function rankImage(url: string): number { const path = url.toLowerCase(); - if (/\/(product|pdp|main|hero|primary)\//.test(path)) return 0; - if (/\/(lifestyle|action|model)\//.test(path)) return 1; - if (/\/(detail|zoom|swatch)\//.test(path)) return 2; + if (PRODUCT_PATH_PATTERN.test(path)) return 0; + if (LIFESTYLE_PATH_PATTERN.test(path)) return 1; + if (DETAIL_PATH_PATTERN.test(path)) return 2; return 3; } diff --git a/packages/analytics/src/core/entity-resolver.ts b/packages/analytics/src/core/entity-resolver.ts index 4b0a9c4a50..5532202c1d 100644 --- a/packages/analytics/src/core/entity-resolver.ts +++ b/packages/analytics/src/core/entity-resolver.ts @@ -21,6 +21,9 @@ const CONFIDENCE_HIGH = 0.9; const CONFIDENCE_MEDIUM = 0.8; const CONFIDENCE_LOW = 0.65; const MAX_BLOCK_SIZE = 5000; +const URL_QUERY_OR_HASH_PATTERN = /[?#].*$/; +const FILE_EXTENSION_PATTERN = /\.\w+$/; +const WHITESPACE_SPLIT_PATTERN = /\s+/; // ── Normalization ───────────────────────────────────────────────────── @@ -48,11 +51,8 @@ function canonicalId(brand: string, name: string): string { function extractSlug(url: string): string { if (!url) return ''; - const parts = url - .replace(/[?#].*$/, '') - .split('/') - .filter(Boolean); - return parts.at(-1)?.replace(/\.\w+$/, '') ?? ''; + const parts = url.replace(URL_QUERY_OR_HASH_PATTERN, '').split('/').filter(Boolean); + return parts.at(-1)?.replace(FILE_EXTENSION_PATTERN, '') ?? ''; } // ── Token Sort Ratio ────────────────────────────────────────────────── @@ -63,7 +63,8 @@ function extractSlug(url: string): string { * Good enough for product name matching without a heavy dep. */ function tokenSortRatio(a: string, b: string): number { - const sortTokens = (s: string) => s.toLowerCase().split(/\s+/).sort().join(' '); + const sortTokens = (s: string) => + s.toLowerCase().split(WHITESPACE_SPLIT_PATTERN).sort().join(' '); const sa = sortTokens(a); const sb = sortTokens(b); diff --git a/packages/analytics/src/core/query-builder.ts b/packages/analytics/src/core/query-builder.ts index 73538c9928..654197e44b 100644 --- a/packages/analytics/src/core/query-builder.ts +++ b/packages/analytics/src/core/query-builder.ts @@ -15,6 +15,7 @@ import { // ── SQL Fragments ───────────────────────────────────────────────────── +// biome-ignore lint/complexity/noStaticOnlyClass: Static SQL helpers are intentionally namespaced. export class SQLFragments { /** Escape single quotes for safe SQL string interpolation. */ static escapeSql(value: string): string { diff --git a/packages/analytics/src/core/spec-parser.ts b/packages/analytics/src/core/spec-parser.ts index f85c3b374d..b60eef99a5 100644 --- a/packages/analytics/src/core/spec-parser.ts +++ b/packages/analytics/src/core/spec-parser.ts @@ -57,8 +57,13 @@ const TEMP_RANGE = /(-?\d+)\s*\/\s*(-?\d+)\s*°?\s*([FC])\b/i; const TEMP_SINGLE = /(-?\d+)\s*°?\s*([FC])\b/i; const FILL_POWER = /(\d{3,4})\s*[-‑]?\s*(?:fill|fp)\b/i; const WATERPROOF = /(\d[\d,]*)\s*(?:k\s*)?mm\b/i; +const WATERPROOF_K_MULTIPLIER_PATTERN = /(\d[\d,]*)\s*k\s*mm\b/i; const SEASON = /([1-4])\s*[-‑]?\s*seasons?\b/i; const GENDER = /\b(men'?s?|women'?s?|womens|mens|unisex|kids?|youth|boys?|girls?|junior)\b/i; +const COMMA_PATTERN = /,/g; +const MEN_PATTERN = /men/; +const WOMEN_PATTERN = /women/; +const YOUTH_PATTERN = /kid|youth|boy|girl|junior/; const FABRIC_PATTERNS = [ /\b(gore[-‑]?tex)\b/i, @@ -134,9 +139,9 @@ export function parseFillPower(text: string): number | null { export function parseWaterproofRating(text: string): number | null { const match = WATERPROOF.exec(text); if (match?.[1] !== undefined) { - const raw = Number.parseInt(match[1].replace(/,/g, ''), 10); + const raw = Number.parseInt(match[1].replace(COMMA_PATTERN, ''), 10); // If "k" or "K" prefix was captured in the regex (e.g., "20k mm"), multiply by 1000 - const hasKMultiplier = /(\d[\d,]*)\s*k\s*mm\b/i.exec(text); + const hasKMultiplier = WATERPROOF_K_MULTIPLIER_PATTERN.exec(text); return hasKMultiplier ? raw * 1000 : raw; } return null; @@ -149,9 +154,9 @@ export function parseSeasons(text: string): string | null { function normalizeGender(raw: string): string { const lower = raw.toLowerCase(); - if (/men/.test(lower) && !/women/.test(lower)) return 'men'; - if (/women/.test(lower)) return 'women'; - if (/kid|youth|boy|girl|junior/.test(lower)) return 'youth'; + if (MEN_PATTERN.test(lower) && !WOMEN_PATTERN.test(lower)) return 'men'; + if (WOMEN_PATTERN.test(lower)) return 'women'; + if (YOUTH_PATTERN.test(lower)) return 'youth'; return 'unisex'; } diff --git a/packages/analytics/test/integration/catalog-mode.test.ts b/packages/analytics/test/integration/catalog-mode.test.ts index da10231ddc..364b126f88 100644 --- a/packages/analytics/test/integration/catalog-mode.test.ts +++ b/packages/analytics/test/integration/catalog-mode.test.ts @@ -9,10 +9,11 @@ */ import { CatalogCacheManager } from '@packrat/analytics/core/catalog-cache'; +import { nodeEnv } from '@packrat/env/node'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const hasCatalogCreds = - !!process.env.R2_CATALOG_TOKEN && !!process.env.R2_CATALOG_URI && !!process.env.R2_WAREHOUSE_NAME; + !!nodeEnv.R2_CATALOG_TOKEN && !!nodeEnv.R2_CATALOG_URI && !!nodeEnv.R2_WAREHOUSE_NAME; describe.skipIf(!hasCatalogCreds)('catalog mode integration', () => { let cache: CatalogCacheManager; diff --git a/packages/analytics/test/integration/connection.test.ts b/packages/analytics/test/integration/connection.test.ts index 687682176e..81fa66abf2 100644 --- a/packages/analytics/test/integration/connection.test.ts +++ b/packages/analytics/test/integration/connection.test.ts @@ -8,11 +8,12 @@ import type { DuckDBConnection } from '@duckdb/node-api'; import { DuckDBInstance } from '@duckdb/node-api'; import { configureS3, createCatalogConnection } from '@packrat/analytics/core/connection'; +import { nodeEnv } from '@packrat/env/node'; import { describe, expect, it } from 'vitest'; -const hasS3Creds = !!process.env.R2_ACCESS_KEY_ID && !!process.env.R2_SECRET_ACCESS_KEY; +const hasS3Creds = !!nodeEnv.R2_ACCESS_KEY_ID && !!nodeEnv.R2_SECRET_ACCESS_KEY; const hasCatalogCreds = - !!process.env.R2_CATALOG_TOKEN && !!process.env.R2_CATALOG_URI && !!process.env.R2_WAREHOUSE_NAME; + !!nodeEnv.R2_CATALOG_TOKEN && !!nodeEnv.R2_CATALOG_URI && !!nodeEnv.R2_WAREHOUSE_NAME; describe.skipIf(!hasS3Creds)('configureS3', () => { let conn: DuckDBConnection; @@ -23,8 +24,7 @@ describe.skipIf(!hasS3Creds)('configureS3', () => { await configureS3(conn); - const bucketName = - process.env.PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME || process.env.R2_BUCKET_NAME; + const bucketName = nodeEnv.PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME || nodeEnv.R2_BUCKET_NAME; if (!bucketName) return; // Verify httpfs can list files from R2 diff --git a/packages/analytics/test/integration/local-mode.test.ts b/packages/analytics/test/integration/local-mode.test.ts index f1562c046e..21b728695d 100644 --- a/packages/analytics/test/integration/local-mode.test.ts +++ b/packages/analytics/test/integration/local-mode.test.ts @@ -9,12 +9,13 @@ import { existsSync, rmSync } from 'node:fs'; import { LocalCacheManager } from '@packrat/analytics/core/local-cache'; +import { nodeEnv } from '@packrat/env/node'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const DEFAULT_CACHE_DIR = 'data/cache'; const FRESH_CACHE_DIR = 'data/test-integration-cache'; const hasExistingCache = existsSync(`${DEFAULT_CACHE_DIR}/packrat_cache.duckdb`); -const hasS3Creds = !!process.env.R2_ACCESS_KEY_ID && !!process.env.R2_SECRET_ACCESS_KEY; +const hasS3Creds = !!nodeEnv.R2_ACCESS_KEY_ID && !!nodeEnv.R2_SECRET_ACCESS_KEY; const canRun = hasExistingCache || hasS3Creds; describe.skipIf(!canRun)('local mode integration', () => { diff --git a/packages/api-client/package.json b/packages/api-client/package.json new file mode 100644 index 0000000000..969a2fc260 --- /dev/null +++ b/packages/api-client/package.json @@ -0,0 +1,18 @@ +{ + "name": "@packrat/api-client", + "version": "2.0.21", + "private": true, + "description": "PackRat typed API client — authenticated HTTP client with error handling and MCP result helpers", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts new file mode 100644 index 0000000000..a200728782 --- /dev/null +++ b/packages/api-client/src/index.ts @@ -0,0 +1,154 @@ +/** + * PackRat typed API client + * + * Authenticated HTTP client for the PackRat API with structured error handling + * and MCP tool result helpers. Designed to be imported by packages/mcp and any + * future consumers that need to call the PackRat REST API. + * + * Future work: integrate hc() from hono/client once the workspace is + * configured with TypeScript project references so API declaration files can be + * consumed without dragging in the full API dependency graph. + */ + +// ── Error class ─────────────────────────────────────────────────────────────── + +export interface ApiErrorOptions { + status: number; + body: unknown; +} + +export class ApiError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(message: string, options: ApiErrorOptions) { + super(message); + this.name = 'ApiError'; + this.status = options.status; + this.body = options.body; + } +} + +// ── HTTP client ─────────────────────────────────────────────────────────────── + +export type QueryParams = Record; + +export class PackRatApiClient { + constructor( + private readonly baseUrl: string, + private readonly getAuthToken: () => string, + ) {} + + private get headers(): Record { + const token = this.getAuthToken(); + const base: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (token) { + base.Authorization = `Bearer ${token}`; + } + return base; + } + + async get(path: string, params?: QueryParams): Promise { + const url = new URL(`${this.baseUrl}${path}`); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + } + const response = await fetch(url.toString(), { method: 'GET', headers: this.headers }); + return this.handleResponse(response); + } + + async post(path: string, body?: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: this.headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return this.handleResponse(response); + } + + async put(path: string, body?: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'PUT', + headers: this.headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return this.handleResponse(response); + } + + async patch(path: string, body?: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'PATCH', + headers: this.headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return this.handleResponse(response); + } + + async delete(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'DELETE', + headers: this.headers, + }); + return this.handleResponse(response); + } + + private async handleResponse(response: Response): Promise { + const text = await response.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = text; + } + + if (!response.ok) { + const errorMessage = + typeof body === 'object' && body !== null && 'error' in body + ? String((body as Record).error) + : `HTTP ${response.status}: ${response.statusText}`; + throw new ApiError(errorMessage, { status: response.status, body }); + } + + return body as T; + } +} + +// ── Client factory ──────────────────────────────────────────────────────────── + +/** + * Create an authenticated PackRat API client. + * + * @param baseUrl - API base URL (e.g. "https://packrat.world") + * @param getAuthToken - Callback that returns the current JWT (may be empty) + */ +export function createPackRatClient(baseUrl: string, getAuthToken: () => string): PackRatApiClient { + return new PackRatApiClient(baseUrl, getAuthToken); +} + +// ── MCP tool result helpers ─────────────────────────────────────────────────── + +/** Format a successful MCP tool result */ +export function ok(data: unknown): { content: [{ type: 'text'; text: string }] } { + return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; +} + +/** Format an error MCP tool result */ +export function err(error: unknown): { content: [{ type: 'text'; text: string }]; isError: true } { + const message = + error instanceof ApiError + ? `API Error (${error.status}): ${error.message}` + : error instanceof Error + ? error.message + : String(error); + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + }; +} diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json new file mode 100644 index 0000000000..45a8d53651 --- /dev/null +++ b/packages/api-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "strict": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/api/TESTING.md b/packages/api/TESTING.md index ba87b32183..661c5f96b2 100644 --- a/packages/api/TESTING.md +++ b/packages/api/TESTING.md @@ -187,7 +187,7 @@ All tests run automatically on: - Pushes to `main` and `dev` branches **CI Workflows:** -- `.github/workflows/check-types.yml` - TypeScript validation +- `.github/workflows/checks.yml` - Monorepo lint and type validation - `.github/workflows/api-tests.yml` - Integration tests - Unit tests run as part of the overall test suite diff --git a/packages/api/container_src/package.json b/packages/api/container_src/package.json index d337dba7a5..090389a273 100644 --- a/packages/api/container_src/package.json +++ b/packages/api/container_src/package.json @@ -1,6 +1,6 @@ { "name": "container", - "version": "2.0.20", + "version": "2.0.21", "type": "module", "dependencies": { "@aws-sdk/client-s3": "^3.0.0", diff --git a/packages/api/container_src/server.ts b/packages/api/container_src/server.ts index dd0b156cff..b73518e25f 100644 --- a/packages/api/container_src/server.ts +++ b/packages/api/container_src/server.ts @@ -16,6 +16,8 @@ const EnvSchema = z.object({ R2_BUCKET_NAME: z.string(), R2_PUBLIC_URL: z.string().url(), GOOGLE_GENAI_API_KEY: z.string(), + CLOUDFLARE_CONTAINER_ID: z.string().optional(), + PORT: z.string().regex(/^\d+$/).optional(), }); type Env = z.infer; @@ -32,14 +34,13 @@ function validateEnv(): Env { // Initialize R2 client let s3Client: S3Client | null = null; let env: Env | null = null; - -// GoogleGenAI client (for video upload) -const googleAi = new GoogleGenAI({ - apiKey: process.env.GOOGLE_GENAI_API_KEY, -}); +let googleAi: GoogleGenAI | null = null; try { env = validateEnv(); + googleAi = new GoogleGenAI({ + apiKey: env.GOOGLE_GENAI_API_KEY, + }); s3Client = new S3Client({ region: 'auto', endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`, @@ -259,6 +260,7 @@ async function uploadVideoToGoogle(videoUrl: string): Promise { const contentType = response.headers.get('content-type') || 'video/mp4'; const videoBlob = new Blob([videoBuffer], { type: contentType }); console.log('Uploading video to Google AI...'); + if (!googleAi) throw new Error('Google AI client not initialized — check GOOGLE_GENAI_API_KEY'); const myfile = await googleAi.files.upload({ file: videoBlob, config: { mimeType: videoBlob.type }, @@ -420,7 +422,7 @@ async function fetchTikTokPostData( // Root endpoint app.get('/', (c) => { // Container instance ID (provided by Cloudflare Container runtime) - const instanceId = process.env.CLOUDFLARE_CONTAINER_ID || 'unknown'; + const instanceId = env?.CLOUDFLARE_CONTAINER_ID ?? 'unknown'; return c.json({ service: 'tiktok-container', instanceId, @@ -581,7 +583,7 @@ app.notFound((c) => { ); }); -const port = process.env.PORT || 8080; +const port = env?.PORT ?? '8080'; console.log(`TikTok container service starting on port ${port}`); diff --git a/packages/api/migrate.ts b/packages/api/migrate.ts index 6ec2da0c2e..cf0c87dcd4 100644 --- a/packages/api/migrate.ts +++ b/packages/api/migrate.ts @@ -1,6 +1,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { neon, neonConfig } from '@neondatabase/serverless'; +import { nodeEnv } from '@packrat/env/node'; import { drizzle } from 'drizzle-orm/neon-http'; import { migrate } from 'drizzle-orm/neon-http/migrator'; import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; @@ -33,13 +34,10 @@ const isStandardPostgresUrl = (url: string) => { }; async function runMigrations() { - if (!process.env.NEON_DATABASE_URL) { - throw new Error('NEON_DATABASE_URL is not set'); - } + const url = nodeEnv.NEON_DATABASE_URL; + if (!url) throw new Error('NEON_DATABASE_URL is required'); console.log('Running migrations...'); - - const url = process.env.NEON_DATABASE_URL; if (isStandardPostgresUrl(url)) { // Use node-postgres for standard PostgreSQL console.log('Using PostgreSQL migrations...'); diff --git a/packages/api/package.json b/packages/api/package.json index bf15ff0793..5388a3a58a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,12 +1,13 @@ { "name": "@packrat/api", - "version": "2.0.20", + "version": "2.0.21", "scripts": { "check-types": "tsc --noEmit", "check-types-watch": "tsc --noEmit --watch", "db:generate": "drizzle-kit generate --dialect=postgresql --schema=src/db/schema.ts --out=./drizzle", "db:migrate": "bun run ./migrate.ts", "db:seed": "bun run ./src/db/seed.ts", + "db:seed:e2e-user": "bun run ./src/db/seed-e2e-user.ts", "deploy": "wrangler deploy --minify", "deploy:dev": "wrangler deploy --minify -e=dev", "dev": "wrangler dev -e=dev", @@ -27,6 +28,7 @@ "@hono/zod-validator": "^0.7.6", "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", + "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", "@scalar/hono-api-reference": "^0.10.7", "@types/nodemailer": "^6.4.17", diff --git a/packages/api/src/db/seed-e2e-user.ts b/packages/api/src/db/seed-e2e-user.ts new file mode 100644 index 0000000000..e6e7b477b5 --- /dev/null +++ b/packages/api/src/db/seed-e2e-user.ts @@ -0,0 +1,96 @@ +/** + * Idempotent upsert of the E2E test user. + * + * Usage: + * NEON_DATABASE_URL= E2E_TEST_EMAIL=... E2E_TEST_PASSWORD=... \ + * bun run packages/api/src/db/seed-e2e-user.ts + * + * Re-running is safe: if the user exists, the password hash and + * `emailVerified=true` flag are refreshed; otherwise the user is created. + */ + +import { neon, neonConfig } from '@neondatabase/serverless'; +import { nodeEnv } from '@packrat/env/node'; +import { eq } from 'drizzle-orm'; +import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; +import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { Client } from 'pg'; +import WebSocket from 'ws'; +import { hashPassword } from '../utils/auth'; +import * as schema from './schema'; + +neonConfig.webSocketConstructor = WebSocket; + +const isStandardPostgresUrl = (url: string) => { + try { + const u = new URL(url); + const host = u.hostname.toLowerCase(); + const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); + const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + return u.protocol === 'postgres:' && !isNeonTech && !isNeonCom; + } catch { + return false; + } +}; + +async function seedE2EUser() { + const dbUrl = nodeEnv.NEON_DATABASE_URL; + const email = nodeEnv.E2E_TEST_EMAIL; + const password = nodeEnv.E2E_TEST_PASSWORD; + + if (!dbUrl) throw new Error('NEON_DATABASE_URL is required'); + if (!email) throw new Error('E2E_TEST_EMAIL is required'); + if (!password) throw new Error('E2E_TEST_PASSWORD is required'); + + const normalizedEmail = email.toLowerCase(); + + type SeedDatabase = NodePgDatabase | NeonHttpDatabase; + let db: SeedDatabase; + let pgClient: Client | undefined; + + if (isStandardPostgresUrl(dbUrl)) { + pgClient = new Client({ connectionString: dbUrl }); + await pgClient.connect(); + db = drizzlePg(pgClient, { schema }); + } else { + db = drizzle(neon(dbUrl), { schema }); + } + + try { + const passwordHash = await hashPassword(password); + const existing = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.email, normalizedEmail)) + .limit(1); + + const existingUser = existing[0]; + if (existingUser) { + await db + .update(schema.users) + .set({ passwordHash, emailVerified: true, updatedAt: new Date() }) + .where(eq(schema.users.id, existingUser.id)); + console.log(`E2E user refreshed: ${normalizedEmail} (id=${existingUser.id})`); + } else { + const [inserted] = await db + .insert(schema.users) + .values({ + email: normalizedEmail, + passwordHash, + emailVerified: true, + firstName: 'E2E', + lastName: 'Automation', + role: 'USER', + }) + .returning(); + console.log(`E2E user created: ${normalizedEmail} (id=${inserted?.id})`); + } + } finally { + await pgClient?.end(); + } +} + +seedE2EUser().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/api/src/db/seed.ts b/packages/api/src/db/seed.ts index aa0c5e3f6b..23698d37c2 100644 --- a/packages/api/src/db/seed.ts +++ b/packages/api/src/db/seed.ts @@ -17,6 +17,7 @@ */ import { neon, neonConfig } from '@neondatabase/serverless'; +import { nodeEnv } from '@packrat/env/node'; import { and, eq } from 'drizzle-orm'; import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -1827,10 +1828,8 @@ const FEATURED_TEMPLATES: SeedTemplate[] = [ // ─── Seed Function ────────────────────────────────────────────────────────── async function seed() { - const dbUrl = process.env.NEON_DATABASE_URL; - if (!dbUrl) { - throw new Error('NEON_DATABASE_URL environment variable is not set'); - } + const dbUrl = nodeEnv.NEON_DATABASE_URL; + if (!dbUrl) throw new Error('NEON_DATABASE_URL is required'); // Get optional admin user ID from CLI args const argUserIdRaw = process.argv[2] ? Number.parseInt(process.argv[2], 10) : undefined; diff --git a/packages/api/src/routes/admin/analytics/catalog.ts b/packages/api/src/routes/admin/analytics/catalog.ts new file mode 100644 index 0000000000..5ee80ad5a7 --- /dev/null +++ b/packages/api/src/routes/admin/analytics/catalog.ts @@ -0,0 +1,437 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { createDb } from '@packrat/api/db'; +import { catalogItems, etlJobs } from '@packrat/api/db/schema'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import type { Env } from '@packrat/api/types/env'; +import { and, avg, count, desc, gt, isNotNull, max, min, sql } from 'drizzle-orm'; + +export const catalogRoutes = new OpenAPIHono<{ Bindings: Env }>(); + +// ─── GET /overview ────────────────────────────────────────────────────────── + +const getOverviewRoute = createRoute({ + method: 'get', + path: '/overview', + tags: ['Admin'], + summary: 'Catalog data lake overview', + description: + 'Aggregate statistics across the gear catalog — totals, pricing, availability, and embedding coverage (Admin only)', + responses: { + 200: { + description: 'Catalog overview', + content: { + 'application/json': { + schema: z.object({ + totalItems: z.number(), + totalBrands: z.number(), + avgPrice: z.number().nullable(), + minPrice: z.number().nullable(), + maxPrice: z.number().nullable(), + embeddingCoverage: z.object({ + total: z.number(), + withEmbedding: z.number(), + pct: z.number(), + }), + availability: z.array( + z.object({ + status: z.string().nullable(), + count: z.number(), + }), + ), + addedLast30Days: z.number(), + }), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +catalogRoutes.openapi(getOverviewRoute, async (c) => { + const db = createDb(c); + + try { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [totals, embeddingStats, availabilityStats, recentCount] = await Promise.all([ + db + .select({ + totalItems: count(), + totalBrands: sql`count(distinct ${catalogItems.brand})`, + avgPrice: avg(catalogItems.price), + minPrice: min(catalogItems.price), + maxPrice: max(catalogItems.price), + }) + .from(catalogItems), + + db + .select({ + total: count(), + withEmbedding: sql`count(${catalogItems.embedding})`, + }) + .from(catalogItems), + + db + .select({ + status: catalogItems.availability, + count: count(), + }) + .from(catalogItems) + .groupBy(catalogItems.availability) + .orderBy(desc(count())), + + db + .select({ count: count() }) + .from(catalogItems) + .where(gt(catalogItems.createdAt, thirtyDaysAgo)), + ]); + + const t = totals[0]; + const e = embeddingStats[0]; + + if (!t || !e) { + return c.json( + { error: 'Failed to fetch catalog overview', code: 'CATALOG_OVERVIEW_ERROR' }, + 500, + ); + } + + const total = e.total; + const withEmbedding = e.withEmbedding; + + return c.json( + { + totalItems: t.totalItems, + totalBrands: t.totalBrands, + avgPrice: t.avgPrice != null ? Math.round(Number(t.avgPrice) * 100) / 100 : null, + minPrice: t.minPrice != null ? Number(t.minPrice) : null, + maxPrice: t.maxPrice != null ? Number(t.maxPrice) : null, + embeddingCoverage: { + total, + withEmbedding, + pct: total > 0 ? Math.round((withEmbedding / total) * 1000) / 10 : 0, + }, + availability: availabilityStats.map((r) => ({ + status: r.status ?? null, + count: r.count, + })), + addedLast30Days: recentCount[0]?.count ?? 0, + }, + 200, + ); + } catch (error) { + console.error('Catalog overview error:', error); + return c.json( + { error: 'Failed to fetch catalog overview', code: 'CATALOG_OVERVIEW_ERROR' }, + 500, + ); + } +}); + +// ─── GET /brands ───────────────────────────────────────────────────────────── + +const getBrandsRoute = createRoute({ + method: 'get', + path: '/brands', + tags: ['Admin'], + summary: 'Top gear brands', + description: 'Top brands by catalog item count with pricing and rating summaries (Admin only)', + request: { + query: z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(25), + }), + }, + responses: { + 200: { + description: 'Brand list', + content: { + 'application/json': { + schema: z.array( + z.object({ + brand: z.string(), + itemCount: z.number(), + avgPrice: z.number().nullable(), + minPrice: z.number().nullable(), + maxPrice: z.number().nullable(), + avgRating: z.number().nullable(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +catalogRoutes.openapi(getBrandsRoute, async (c) => { + const db = createDb(c); + const { limit = 25 } = c.req.valid('query'); + + try { + const brands = await db + .select({ + brand: catalogItems.brand, + itemCount: count(), + avgPrice: avg(catalogItems.price), + minPrice: min(catalogItems.price), + maxPrice: max(catalogItems.price), + avgRating: avg(catalogItems.ratingValue), + }) + .from(catalogItems) + .where(isNotNull(catalogItems.brand)) + .groupBy(catalogItems.brand) + .orderBy(desc(count())) + .limit(limit); + + return c.json( + brands.map((b) => ({ + brand: b.brand ?? '', + itemCount: b.itemCount, + avgPrice: b.avgPrice != null ? Math.round(Number(b.avgPrice) * 100) / 100 : null, + minPrice: b.minPrice != null ? Number(b.minPrice) : null, + maxPrice: b.maxPrice != null ? Number(b.maxPrice) : null, + avgRating: b.avgRating != null ? Math.round(Number(b.avgRating) * 10) / 10 : null, + })), + 200, + ); + } catch (error) { + console.error('Catalog brands error:', error); + return c.json({ error: 'Failed to fetch brand data', code: 'CATALOG_BRANDS_ERROR' }, 500); + } +}); + +// ─── GET /prices ───────────────────────────────────────────────────────────── + +const getPricesRoute = createRoute({ + method: 'get', + path: '/prices', + tags: ['Admin'], + summary: 'Price distribution', + description: 'Distribution of catalog items across price buckets (Admin only)', + responses: { + 200: { + description: 'Price distribution buckets', + content: { + 'application/json': { + schema: z.array( + z.object({ + bucket: z.string(), + count: z.number(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +catalogRoutes.openapi(getPricesRoute, async (c) => { + const db = createDb(c); + + try { + const bucketExpr = sql`CASE + WHEN ${catalogItems.price} < 20 THEN 'Under $20' + WHEN ${catalogItems.price} < 50 THEN '$20–$50' + WHEN ${catalogItems.price} < 100 THEN '$50–$100' + WHEN ${catalogItems.price} < 200 THEN '$100–$200' + WHEN ${catalogItems.price} < 500 THEN '$200–$500' + ELSE 'Over $500' + END`; + + const distribution = await db + .select({ + bucket: bucketExpr, + count: count(), + minForOrder: min(catalogItems.price), + }) + .from(catalogItems) + .where(and(isNotNull(catalogItems.price), gt(catalogItems.price, 0))) + .groupBy(bucketExpr) + .orderBy(min(catalogItems.price)); + + return c.json( + distribution.map((r) => ({ bucket: r.bucket, count: r.count })), + 200, + ); + } catch (error) { + console.error('Catalog prices error:', error); + return c.json( + { error: 'Failed to fetch price distribution', code: 'CATALOG_PRICES_ERROR' }, + 500, + ); + } +}); + +// ─── GET /etl ───────────────────────────────────────────────────────────────── + +const getEtlRoute = createRoute({ + method: 'get', + path: '/etl', + tags: ['Admin'], + summary: 'ETL pipeline history', + description: 'History of catalog data ingestion jobs with success/failure rates (Admin only)', + request: { + query: z.object({ + limit: z.coerce.number().int().min(1).max(200).optional().default(50), + }), + }, + responses: { + 200: { + description: 'ETL job history', + content: { + 'application/json': { + schema: z.object({ + jobs: z.array( + z.object({ + id: z.string(), + status: z.enum(['running', 'completed', 'failed']), + source: z.string(), + filename: z.string(), + scraperRevision: z.string(), + startedAt: z.string(), + completedAt: z.string().nullable(), + totalProcessed: z.number().nullable(), + totalValid: z.number().nullable(), + totalInvalid: z.number().nullable(), + successRate: z.number().nullable(), + }), + ), + summary: z.object({ + totalRuns: z.number(), + completed: z.number(), + failed: z.number(), + totalItemsIngested: z.number(), + }), + }), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +catalogRoutes.openapi(getEtlRoute, async (c) => { + const db = createDb(c); + const { limit = 50 } = c.req.valid('query'); + + try { + const [jobs, summary] = await Promise.all([ + db.select().from(etlJobs).orderBy(desc(etlJobs.startedAt)).limit(limit), + + db + .select({ + totalRuns: count(), + completed: sql`count(*) filter (where ${etlJobs.status} = 'completed')`, + failed: sql`count(*) filter (where ${etlJobs.status} = 'failed')`, + totalItemsIngested: sql`coalesce(sum(${etlJobs.totalValid}), 0)`, + }) + .from(etlJobs), + ]); + + const s = summary[0]; + + return c.json( + { + jobs: jobs.map((j) => ({ + id: j.id, + status: j.status, + source: j.source, + filename: j.filename, + scraperRevision: j.scraperRevision, + startedAt: j.startedAt.toISOString(), + completedAt: j.completedAt?.toISOString() ?? null, + totalProcessed: j.totalProcessed ?? null, + totalValid: j.totalValid ?? null, + totalInvalid: j.totalInvalid ?? null, + successRate: + j.totalProcessed != null && j.totalProcessed > 0 && j.totalValid != null + ? Math.round((j.totalValid / j.totalProcessed) * 1000) / 10 + : null, + })), + summary: { + totalRuns: s?.totalRuns ?? 0, + completed: s?.completed ?? 0, + failed: s?.failed ?? 0, + totalItemsIngested: s?.totalItemsIngested ?? 0, + }, + }, + 200, + ); + } catch (error) { + console.error('Catalog ETL error:', error); + return c.json({ error: 'Failed to fetch ETL history', code: 'CATALOG_ETL_ERROR' }, 500); + } +}); + +// ─── GET /embeddings ────────────────────────────────────────────────────────── + +const getEmbeddingsRoute = createRoute({ + method: 'get', + path: '/embeddings', + tags: ['Admin'], + summary: 'Embedding coverage', + description: 'How many catalog items have vector embeddings vs. are pending (Admin only)', + responses: { + 200: { + description: 'Embedding coverage stats', + content: { + 'application/json': { + schema: z.object({ + total: z.number(), + withEmbedding: z.number(), + pending: z.number(), + coveragePct: z.number(), + }), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +catalogRoutes.openapi(getEmbeddingsRoute, async (c) => { + const db = createDb(c); + + try { + const [total, withEmbedding] = await Promise.all([ + db.select({ count: count() }).from(catalogItems), + db.select({ count: count() }).from(catalogItems).where(isNotNull(catalogItems.embedding)), + ]); + + const totalCount = total[0]?.count ?? 0; + const embeddedCount = withEmbedding[0]?.count ?? 0; + + return c.json( + { + total: totalCount, + withEmbedding: embeddedCount, + pending: totalCount - embeddedCount, + coveragePct: totalCount > 0 ? Math.round((embeddedCount / totalCount) * 1000) / 10 : 0, + }, + 200, + ); + } catch (error) { + console.error('Catalog embeddings error:', error); + return c.json( + { error: 'Failed to fetch embedding stats', code: 'CATALOG_EMBEDDINGS_ERROR' }, + 500, + ); + } +}); diff --git a/packages/api/src/routes/admin/analytics/index.ts b/packages/api/src/routes/admin/analytics/index.ts new file mode 100644 index 0000000000..75fc9750ba --- /dev/null +++ b/packages/api/src/routes/admin/analytics/index.ts @@ -0,0 +1,32 @@ +import { OpenAPIHono } from '@hono/zod-openapi'; +import type { Env } from '@packrat/api/types/env'; +import { catalogRoutes } from './catalog'; +import { platformRoutes } from './platform'; + +export const analyticsRoutes = new OpenAPIHono<{ Bindings: Env }>(); + +// ─── Sub-routers ───────────────────────────────────────────────────────────── + +analyticsRoutes.route('/platform', platformRoutes); +analyticsRoutes.route('/catalog', catalogRoutes); + +// ─── Analytics root ─────────────────────────────────────────────────────────── + +analyticsRoutes.get('/', (c) => + c.json({ + analytics: { + platform: { + growth: '/api/admin/analytics/platform/growth', + activity: '/api/admin/analytics/platform/activity', + breakdown: '/api/admin/analytics/platform/breakdown', + }, + catalog: { + overview: '/api/admin/analytics/catalog/overview', + brands: '/api/admin/analytics/catalog/brands', + prices: '/api/admin/analytics/catalog/prices', + etl: '/api/admin/analytics/catalog/etl', + embeddings: '/api/admin/analytics/catalog/embeddings', + }, + }, + }), +); diff --git a/packages/api/src/routes/admin/analytics/platform.ts b/packages/api/src/routes/admin/analytics/platform.ts new file mode 100644 index 0000000000..b30261e3ee --- /dev/null +++ b/packages/api/src/routes/admin/analytics/platform.ts @@ -0,0 +1,315 @@ +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { createDb } from '@packrat/api/db'; +import { + catalogItems, + packs, + posts, + trailConditionReports, + trips, + users, +} from '@packrat/api/db/schema'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import type { Env } from '@packrat/api/types/env'; +import { and, count, desc, eq, gte, sql } from 'drizzle-orm'; + +export const platformRoutes = new OpenAPIHono<{ Bindings: Env }>(); + +// ─── Schemas ──────────────────────────────────────────────────────────────── + +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), +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getStartDate(period: 'day' | 'week' | 'month', range: number): Date { + const d = new Date(); + if (period === 'day') d.setDate(d.getDate() - range); + else if (period === 'week') d.setDate(d.getDate() - range * 7); + else d.setMonth(d.getMonth() - range); + return d; +} + +// ─── Platform analytics root ───────────────────────────────────────────────── + +platformRoutes.get('/', (c) => + c.json({ + analytics: { + growth: '/api/admin/analytics/platform/growth', + activity: '/api/admin/analytics/platform/activity', + breakdown: '/api/admin/analytics/platform/breakdown', + }, + }), +); + +// ─── GET /growth ───────────────────────────────────────────────────────────── + +const getGrowthRoute = createRoute({ + method: 'get', + path: '/growth', + tags: ['Admin'], + summary: 'Platform growth metrics', + description: + 'Time-series data for user registrations, pack creation, and catalog item additions (Admin only)', + request: { query: PeriodSchema }, + responses: { + 200: { + description: 'Growth time-series data — one entry per period bucket', + content: { + 'application/json': { + schema: z.array( + z.object({ + period: z.string(), + users: z.number(), + packs: z.number(), + catalogItems: z.number(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +platformRoutes.openapi(getGrowthRoute, async (c) => { + const db = createDb(c); + const { period = 'month', range = 12 } = c.req.valid('query'); + const startDate = getStartDate(period, range); + + try { + const [userGrowth, packGrowth, catalogGrowth] = await Promise.all([ + db + .select({ + date: sql`date_trunc(${period}, ${users.createdAt})::date::text`, + count: count(), + }) + .from(users) + .where(gte(users.createdAt, startDate)) + .groupBy(sql`date_trunc(${period}, ${users.createdAt})`) + .orderBy(sql`date_trunc(${period}, ${users.createdAt})`), + + db + .select({ + date: sql`date_trunc(${period}, ${packs.createdAt})::date::text`, + count: count(), + }) + .from(packs) + .where(and(eq(packs.deleted, false), gte(packs.createdAt, startDate))) + .groupBy(sql`date_trunc(${period}, ${packs.createdAt})`) + .orderBy(sql`date_trunc(${period}, ${packs.createdAt})`), + + db + .select({ + date: sql`date_trunc(${period}, ${catalogItems.createdAt})::date::text`, + count: count(), + }) + .from(catalogItems) + .where(gte(catalogItems.createdAt, startDate)) + .groupBy(sql`date_trunc(${period}, ${catalogItems.createdAt})`) + .orderBy(sql`date_trunc(${period}, ${catalogItems.createdAt})`), + ]); + + const userMap: Record = Object.fromEntries( + userGrowth.map((r) => [r.date, r.count]), + ); + const packMap: Record = Object.fromEntries( + packGrowth.map((r) => [r.date, r.count]), + ); + const catalogMap: Record = Object.fromEntries( + catalogGrowth.map((r) => [r.date, r.count]), + ); + const allDates = [ + ...new Set([ + ...userGrowth.map((r) => r.date), + ...packGrowth.map((r) => r.date), + ...catalogGrowth.map((r) => r.date), + ]), + ].sort(); + + return c.json( + allDates.map((date) => ({ + period: date, + users: userMap[date] ?? 0, + packs: packMap[date] ?? 0, + catalogItems: catalogMap[date] ?? 0, + })), + 200, + ); + } catch (error) { + console.error('Analytics growth error:', error); + return c.json({ error: 'Failed to fetch growth data', code: 'ANALYTICS_GROWTH_ERROR' }, 500); + } +}); + +// ─── GET /activity ─────────────────────────────────────────────────────────── + +const getActivityRoute = createRoute({ + method: 'get', + path: '/activity', + tags: ['Admin'], + summary: 'User activity metrics', + description: + 'Time-series data for trips created, trail condition reports, and social posts (Admin only)', + request: { query: PeriodSchema }, + responses: { + 200: { + description: 'Activity time-series data — one entry per period bucket', + content: { + 'application/json': { + schema: z.array( + z.object({ + period: z.string(), + trips: z.number(), + trailReports: z.number(), + posts: z.number(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +platformRoutes.openapi(getActivityRoute, async (c) => { + const db = createDb(c); + const { period = 'month', range = 12 } = c.req.valid('query'); + const startDate = getStartDate(period, range); + + try { + const [tripActivity, trailActivity, postActivity] = await Promise.all([ + db + .select({ + date: sql`date_trunc(${period}, ${trips.createdAt})::date::text`, + count: count(), + }) + .from(trips) + .where(and(eq(trips.deleted, false), gte(trips.createdAt, startDate))) + .groupBy(sql`date_trunc(${period}, ${trips.createdAt})`) + .orderBy(sql`date_trunc(${period}, ${trips.createdAt})`), + + db + .select({ + date: sql`date_trunc(${period}, ${trailConditionReports.createdAt})::date::text`, + count: count(), + }) + .from(trailConditionReports) + .where( + and( + eq(trailConditionReports.deleted, false), + gte(trailConditionReports.createdAt, startDate), + ), + ) + .groupBy(sql`date_trunc(${period}, ${trailConditionReports.createdAt})`) + .orderBy(sql`date_trunc(${period}, ${trailConditionReports.createdAt})`), + + db + .select({ + date: sql`date_trunc(${period}, ${posts.createdAt})::date::text`, + count: count(), + }) + .from(posts) + .where(gte(posts.createdAt, startDate)) + .groupBy(sql`date_trunc(${period}, ${posts.createdAt})`) + .orderBy(sql`date_trunc(${period}, ${posts.createdAt})`), + ]); + + const tripMap: Record = Object.fromEntries( + tripActivity.map((r) => [r.date, r.count]), + ); + const trailMap: Record = Object.fromEntries( + trailActivity.map((r) => [r.date, r.count]), + ); + const postMap: Record = Object.fromEntries( + postActivity.map((r) => [r.date, r.count]), + ); + const allDates = [ + ...new Set([ + ...tripActivity.map((r) => r.date), + ...trailActivity.map((r) => r.date), + ...postActivity.map((r) => r.date), + ]), + ].sort(); + + return c.json( + allDates.map((date) => ({ + period: date, + trips: tripMap[date] ?? 0, + trailReports: trailMap[date] ?? 0, + posts: postMap[date] ?? 0, + })), + 200, + ); + } catch (error) { + console.error('Analytics activity error:', error); + return c.json( + { error: 'Failed to fetch activity data', code: 'ANALYTICS_ACTIVITY_ERROR' }, + 500, + ); + } +}); + +// ─── GET /breakdown ────────────────────────────────────────────────────────── + +const getBreakdownRoute = createRoute({ + method: 'get', + path: '/breakdown', + tags: ['Admin'], + summary: 'Categorical distribution metrics', + description: + 'Breakdown of packs and pack items by category, ordered by count descending (Admin only)', + responses: { + 200: { + description: 'Pack category breakdown, ordered by count descending', + content: { + 'application/json': { + schema: z.array( + z.object({ + category: z.string(), + count: z.number(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +platformRoutes.openapi(getBreakdownRoute, async (c) => { + const db = createDb(c); + + try { + const packsByCategory = await db + .select({ category: packs.category, count: count() }) + .from(packs) + .where(eq(packs.deleted, false)) + .groupBy(packs.category) + .orderBy(desc(count())); + + return c.json( + packsByCategory.map((r) => ({ + category: r.category ?? 'Uncategorized', + count: r.count, + })), + 200, + ); + } catch (error) { + console.error('Analytics breakdown error:', error); + return c.json( + { error: 'Failed to fetch breakdown data', code: 'ANALYTICS_BREAKDOWN_ERROR' }, + 500, + ); + } +}); diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 1c1c9cbfc8..3dc2ef8fcd 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -9,6 +9,7 @@ import { and, count, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { basicAuth } from 'hono/basic-auth'; import { html, raw } from 'hono/html'; import { sign, verify } from 'hono/jwt'; +import { analyticsRoutes } from './analytics'; const ADMIN_TOKEN_TTL_SECONDS = 3600; // 1 hour @@ -108,6 +109,7 @@ const adminLayout = (title: string, content: unknown) => html` Users Packs Catalog + Analytics
@@ -1107,6 +1109,8 @@ adminRoutes.openapi(getCatalogListRoute, async (c) => { } }); +adminRoutes.route('/analytics', analyticsRoutes); + // ─── Action routes ──────────────────────────────────────────────────────────── const deleteUserRoute = createRoute({ @@ -1303,5 +1307,4 @@ adminRoutes.openapi(updateCatalogItemRoute, async (c) => { return c.json({ error: 'Failed to update catalog item', code: 'UPDATE_ERROR' }, 500); } }); - export { adminRoutes }; diff --git a/packages/api/src/routes/ai/index.ts b/packages/api/src/routes/ai/index.ts index fbe51d9086..561fb77745 100644 --- a/packages/api/src/routes/ai/index.ts +++ b/packages/api/src/routes/ai/index.ts @@ -43,7 +43,8 @@ aiRoutes.openapi(ragSearchRoute, async (c) => { const { q: query, limit } = c.req.valid('query'); const aiService = new AIService(c); const result = await aiService.searchPackratOutdoorGuidesRAG(query, limit); - return c.json(result as any, 200); + const response = RagSearchResponseSchema.parse(result); + return c.json(response, 200); } catch (error) { console.error('RAG search error:', error); return c.json({ error: 'Failed to search outdoor guides' }, 500); diff --git a/packages/api/src/routes/guides/getGuidesRoute.ts b/packages/api/src/routes/guides/getGuidesRoute.ts index 5ce760cb09..f59679c96e 100644 --- a/packages/api/src/routes/guides/getGuidesRoute.ts +++ b/packages/api/src/routes/guides/getGuidesRoute.ts @@ -10,6 +10,9 @@ import { getEnv } from '@packrat/api/utils/env-validation'; import { asNumber, asString, isArray } from '@packrat/guards'; import matter from 'gray-matter'; +const GUIDE_FILE_EXTENSION_PATTERN = /\.(mdx?|md)$/; +const DASH_PATTERN = /-/g; + export const routeDefinition = createRoute({ method: 'get', path: '/', @@ -89,12 +92,12 @@ export const handler: RouteHandler = async (c) => { } return { - id: obj.key.replace(/\.(mdx?|md)$/, ''), // Remove .mdx or .md extension + id: obj.key.replace(GUIDE_FILE_EXTENSION_PATTERN, ''), // Remove .mdx or .md extension key: obj.key, title: asString(frontmatter.title) || obj.customMetadata?.title || - obj.key.replace(/\.(mdx?|md)$/, '').replace(/-/g, ' '), + obj.key.replace(GUIDE_FILE_EXTENSION_PATTERN, '').replace(DASH_PATTERN, ' '), category: obj.customMetadata?.category || 'general', categories: (frontmatter.categories as string[]) || [], description: asString(frontmatter.description) || obj.customMetadata?.description || '', diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index a936431fb7..a6c1418da7 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -50,3 +50,6 @@ routes.route('/', publicRoutes); routes.route('/', protectedRoutes); export { routes }; + +/** Full type of the PackRat Hono app — used by `hc()` in api-client. */ +export type AppRoutes = typeof routes; diff --git a/packages/api/test/image-detection.test.ts b/packages/api/test/image-detection.test.ts index 498a2140ae..03c5baa7d4 100644 --- a/packages/api/test/image-detection.test.ts +++ b/packages/api/test/image-detection.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { seedAndLoginTestUser, seedTestUser } from './utils/db-helpers'; +import { seedAndLoginTestUser } from './utils/db-helpers'; import { api, apiWithAuth, @@ -9,7 +9,7 @@ import { } from './utils/test-helpers'; describe('Image Detection Routes', () => { - let testUser: Awaited>; + let testUser: Awaited>; beforeEach(async () => { testUser = await seedAndLoginTestUser(); diff --git a/packages/api/test/pack-templates.test.ts b/packages/api/test/pack-templates.test.ts index a2df6471e5..653ae7274d 100644 --- a/packages/api/test/pack-templates.test.ts +++ b/packages/api/test/pack-templates.test.ts @@ -4,7 +4,6 @@ import { seedPackTemplate, seedPackTemplateItem, seedPackTemplateItems, - seedTestUser, } from './utils/db-helpers'; import { api, @@ -16,7 +15,7 @@ import { } from './utils/test-helpers'; describe('Pack Templates Routes', () => { - let testUser: Awaited>; + let testUser: Awaited>; // Re-seed user before each test (global beforeEach truncates all tables) beforeEach(async () => { diff --git a/packages/api/test/setup.ts b/packages/api/test/setup.ts index e843c2e067..54cd7b32fa 100644 --- a/packages/api/test/setup.ts +++ b/packages/api/test/setup.ts @@ -237,11 +237,17 @@ vi.mock('@packrat/api/utils/ai/tools', () => ({ // tests that exercise real methods still work. vi.mock('@packrat/api/services/catalogService', async (importOriginal) => { const actual = await importOriginal(); + type BatchVectorSearchResult = Awaited< + ReturnType['batchVectorSearch']> + >; + return { ...actual, CatalogService: class extends actual.CatalogService { - // biome-ignore lint/suspicious/noExplicitAny: test mock signature matches real method - async batchVectorSearch(queries: string[], _limit?: number): Promise { + async batchVectorSearch( + queries: string[], + _limit?: number, + ): Promise { return { items: queries.map(() => [ { @@ -253,7 +259,7 @@ vi.mock('@packrat/api/services/catalogService', async (importOriginal) => { images: [], }, ]), - }; + } as unknown as BatchVectorSearchResult; } }, }; diff --git a/packages/api/test/upload.test.ts b/packages/api/test/upload.test.ts index ae82a6f008..b040cb1973 100644 --- a/packages/api/test/upload.test.ts +++ b/packages/api/test/upload.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { seedAndLoginTestUser, seedTestUser } from './utils/db-helpers'; +import { seedAndLoginTestUser } from './utils/db-helpers'; import { api, apiWithAuth, @@ -10,7 +10,7 @@ import { } from './utils/test-helpers'; describe('Upload Routes', () => { - let testUser: Awaited>; + let testUser: Awaited>; beforeEach(async () => { vi.clearAllMocks(); diff --git a/packages/checks/package.json b/packages/checks/package.json new file mode 100644 index 0000000000..57b2918104 --- /dev/null +++ b/packages/checks/package.json @@ -0,0 +1,9 @@ +{ + "name": "@packrat/checks", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "check:magic-strings": "bun ./src/check-magic-strings.ts" + } +} diff --git a/packages/checks/src/check-magic-strings.ts b/packages/checks/src/check-magic-strings.ts new file mode 100644 index 0000000000..b042732ebe --- /dev/null +++ b/packages/checks/src/check-magic-strings.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env bun + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(import.meta.dir, '..', '..', '..'); +const SCAN_ROOTS = ['apps', 'packages']; +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.expo']); +const TARGET_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']); +const EXCLUDED_FILE_PATTERNS = [/\.test\./, /\.spec\./, /\.stories\./, /\.d\.ts$/]; +const RELATIVE_PATH_PATTERN = /^\.{0,2}\//; +const SLASHED_WORD_PATH_PATTERN = /^[\w.-]+(\/[\w.-]+)+$/; +const WHITESPACE_PATTERN = /\s+/; + +const MIN_LITERAL_LENGTH = 4; +const MIN_OCCURRENCES_PER_FILE = 3; +const MAX_LITERAL_LENGTH = 80; + +const ALLOWLIST = new Set([ + 'use client', + 'dark', + 'light', + 'system', + 'POST', + 'GET', + 'PUT', + 'DELETE', + 'PATCH', +]); + +// Matches single-quoted and double-quoted string literals (including escaped quotes). +const STRING_LITERAL_PATTERN = /(['"])((?:\\.|(?!\1).)+)\1/g; + +interface LiteralLocation { + line: number; +} + +interface FileViolation { + file: string; + literal: string; + count: number; + lines: number[]; +} + +function isTargetFile(filePath: string): boolean { + const extension = filePath.slice(filePath.lastIndexOf('.')); + if (!TARGET_EXTENSIONS.has(extension)) return false; + return !EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(filePath)); +} + +function shouldIgnoreLiteral(value: string): boolean { + if (ALLOWLIST.has(value)) return true; + if (value.length < MIN_LITERAL_LENGTH || value.length > MAX_LITERAL_LENGTH) return true; + if (value.includes('${')) return true; + if (value.startsWith('http://') || value.startsWith('https://')) return true; + if (RELATIVE_PATH_PATTERN.test(value)) return true; + if (SLASHED_WORD_PATH_PATTERN.test(value)) return true; + const words = value.trim().split(WHITESPACE_PATTERN); + if (words.length > 3) return true; + if (value.startsWith('#')) return true; + if (value.startsWith('--')) return true; + return false; +} + +function shouldSkipLine(line: string): boolean { + const trimmed = line.trimStart(); + return ( + trimmed.startsWith('import ') || + trimmed.startsWith('export ') || + trimmed.startsWith('//') || + trimmed.startsWith('*') || + trimmed.startsWith('/*') + ); +} + +function collectFileViolations(file: string): FileViolation[] { + const fullPath = join(ROOT, file); + let content = ''; + + try { + content = readFileSync(fullPath, 'utf8'); + } catch { + return []; + } + + const byLiteral = new Map(); + const lines = content.split('\n'); + + for (let index = 0; index < lines.length; index++) { + const line = lines[index] ?? ''; + if (shouldSkipLine(line)) continue; + + const matches = line.matchAll(STRING_LITERAL_PATTERN); + for (const match of matches) { + const value = match[2]; + if (!value || shouldIgnoreLiteral(value)) continue; + + const current = byLiteral.get(value) ?? []; + current.push({ line: index + 1 }); + byLiteral.set(value, current); + } + } + + const violations: FileViolation[] = []; + for (const [literal, locations] of byLiteral.entries()) { + if (locations.length < MIN_OCCURRENCES_PER_FILE) continue; + violations.push({ + file, + literal, + count: locations.length, + lines: locations.map((location) => location.line), + }); + } + + return violations.sort((a, b) => b.count - a.count); +} + +const targetFiles: string[] = []; + +function walkDir(dir: string, relDir: string): void { + let entries: string[] = []; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry)) continue; + + const fullPath = join(dir, entry); + const relPath = `${relDir}/${entry}`; + let isDirectory = false; + + try { + isDirectory = statSync(fullPath).isDirectory(); + } catch { + continue; + } + + if (isDirectory) { + walkDir(fullPath, relPath); + continue; + } + + if (isTargetFile(relPath)) targetFiles.push(relPath); + } +} + +for (const root of SCAN_ROOTS) { + walkDir(join(ROOT, root), root); +} + +const violations = targetFiles.flatMap((file) => collectFileViolations(file)); + +if (violations.length === 0) { + console.log('No repeated magic strings found.'); + process.exit(0); +} + +console.log('Magic string candidates found. Prefer constants/enums in shared config objects:\n'); +for (const violation of violations) { + console.log( + `${violation.file}: "${violation.literal}" appears ${violation.count} times (lines: ${violation.lines.join(', ')})`, + ); +} + +console.log( + '\nTip: centralize repeated literals into frozen constants (Object.freeze) or enum-like objects.', +); + +const strictMode = process.argv.includes('--strict'); +if (strictMode) process.exit(1); diff --git a/packages/cli/package.json b/packages/cli/package.json index 989c01b708..347cd1f174 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,8 +1,11 @@ { "name": "@packrat/cli", - "version": "2.0.20", + "version": "2.0.21", "private": true, "type": "module", + "bin": { + "packrat": "./src/index.ts" + }, "scripts": { "check-types": "tsc --noEmit", "packrat": "bun run src/index.ts" @@ -10,10 +13,12 @@ "dependencies": { "@duckdb/node-api": "1.5.0-r.1", "@packrat/analytics": "workspace:*", + "@packrat/env": "workspace:*", "chalk": "catalog:", "citty": "^0.2.1", "cli-table3": "^0.6.5", - "consola": "^3.4.2" + "consola": "^3.4.2", + "zod": "catalog:" }, "devDependencies": { "@types/bun": "latest" diff --git a/packages/cli/scripts/smoke-test.ts b/packages/cli/scripts/smoke-test.ts index 993a22239b..f588fb9ef6 100644 --- a/packages/cli/scripts/smoke-test.ts +++ b/packages/cli/scripts/smoke-test.ts @@ -8,14 +8,15 @@ */ import { DuckDBInstance } from '@duckdb/node-api'; +import { nodeEnv } from '@packrat/env/node'; -const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID; -const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY; -const R2_ENDPOINT_URL = process.env.R2_ENDPOINT_URL; +const R2_ACCESS_KEY_ID = nodeEnv.R2_ACCESS_KEY_ID; +const R2_SECRET_ACCESS_KEY = nodeEnv.R2_SECRET_ACCESS_KEY; +const R2_ENDPOINT_URL = nodeEnv.R2_ENDPOINT_URL; const R2_BUCKET_NAME = - process.env.PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME ?? - process.env.PACKRAT_ITEMS_BUCKET_R2_BUCKET_NAME ?? - process.env.R2_BUCKET_NAME ?? + nodeEnv.PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME ?? + nodeEnv.PACKRAT_ITEMS_BUCKET_R2_BUCKET_NAME ?? + nodeEnv.R2_BUCKET_NAME ?? 'packrat-scrapy-bucket'; function escapeSql(value: string): string { diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts new file mode 100644 index 0000000000..3b341409fc --- /dev/null +++ b/packages/cli/src/args.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +const preprocessRequiredNumber = (value: unknown) => + typeof value === 'string' && value.trim() === '' ? Number.NaN : value; + +function parseWithMessage(options: { + schema: z.ZodType; + value: unknown; + argName: string; + expected: string; +}): T { + const parsed = options.schema.safeParse(options.value); + if (!parsed.success) { + throw new Error( + `Invalid ${options.argName}: "${String(options.value)}". Expected ${options.expected}.`, + ); + } + return parsed.data; +} + +const positiveInteger = z.preprocess( + preprocessRequiredNumber, + z.coerce.number().finite().int().positive(), +); +const nonNegativeNumber = z.preprocess( + preprocessRequiredNumber, + z.coerce.number().finite().nonnegative(), +); +const percentage = z.preprocess( + preprocessRequiredNumber, + z.coerce.number().finite().min(0).max(100), +); +const confidence = z.preprocess(preprocessRequiredNumber, z.coerce.number().finite().min(0).max(1)); +const optionalNumber = z.preprocess( + (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value), + z.coerce.number().finite().optional(), +); + +export function parsePositiveIntArg(value: unknown, argName: string): number { + return parseWithMessage({ + schema: positiveInteger, + value, + argName, + expected: 'a positive integer', + }); +} + +export function parseNonNegativeNumberArg(value: unknown, argName: string): number { + return parseWithMessage({ + schema: nonNegativeNumber, + value, + argName, + expected: 'a non-negative number', + }); +} + +export function parseOptionalNumberArg(value: unknown, argName: string): number | undefined { + return parseWithMessage({ + schema: optionalNumber, + value, + argName, + expected: 'a valid number', + }); +} + +export function parsePercentageArg(value: unknown, argName: string): number { + return parseWithMessage({ + schema: percentage, + value, + argName, + expected: 'a percentage between 0 and 100', + }); +} + +export function parseConfidenceArg(value: unknown, argName: string): number { + return parseWithMessage({ + schema: confidence, + value, + argName, + expected: 'a value between 0 and 1', + }); +} + +export function parseCsvArg(value: string | undefined): string[] | undefined { + if (!value) return undefined; + const parsed = value + .split(',') + .map((site) => site.trim()) + .filter((site) => site.length > 0); + return parsed.length > 0 ? parsed : undefined; +} diff --git a/packages/cli/src/commands/brand.ts b/packages/cli/src/commands/brand.ts index b05fd89950..c6d4afb9e2 100644 --- a/packages/cli/src/commands/brand.ts +++ b/packages/cli/src/commands/brand.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -9,7 +10,7 @@ export default defineCommand({ }, async run({ args }) { const cache = await ensureCache(); - const rows = await cache.analyzeBrand(args.name, args.sites?.split(',')); + const rows = await cache.analyzeBrand(args.name, parseCsvArg(args.sites)); printTable(rows as unknown as Record[], { title: `Brand Analysis: "${args.name}"`, }); diff --git a/packages/cli/src/commands/brands.ts b/packages/cli/src/commands/brands.ts index c29fd670b0..fff4abd6b5 100644 --- a/packages/cli/src/commands/brands.ts +++ b/packages/cli/src/commands/brands.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -9,7 +10,7 @@ export default defineCommand({ }, async run({ args }) { const cache = await ensureCache(); - const rows = await cache.getTopBrands(Number(args.limit), args.site); + const rows = await cache.getTopBrands(parsePositiveIntArg(args.limit, '--limit'), args.site); printTable(rows as unknown as Record[], { title: 'Top Brands' }); }, }); diff --git a/packages/cli/src/commands/build-specs.ts b/packages/cli/src/commands/build-specs.ts index d22b51eda8..375cbc5149 100644 --- a/packages/cli/src/commands/build-specs.ts +++ b/packages/cli/src/commands/build-specs.ts @@ -1,12 +1,12 @@ import { SpecParser } from '@packrat/analytics'; import { defineCommand } from 'citty'; import consola from 'consola'; -import { getCache } from '../shared'; +import { ensureCache } from '../shared'; export default defineCommand({ meta: { name: 'build-specs', description: 'Extract structured specs from all products' }, async run() { - const cache = await getCache(); + const cache = await ensureCache(); const conn = cache.getConnection(); consola.start('Building spec table...'); diff --git a/packages/cli/src/commands/category.ts b/packages/cli/src/commands/category.ts index 52515af90f..4ac20e8466 100644 --- a/packages/cli/src/commands/category.ts +++ b/packages/cli/src/commands/category.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -9,7 +10,7 @@ export default defineCommand({ }, async run({ args }) { const cache = await ensureCache(); - const rows = await cache.categoryInsights(args.name, args.sites?.split(',')); + const rows = await cache.categoryInsights(args.name, parseCsvArg(args.sites)); printTable(rows as unknown as Record[], { title: `Category: "${args.name}"` }); }, }); diff --git a/packages/cli/src/commands/compare.ts b/packages/cli/src/commands/compare.ts index 95df91d3e1..bafbd9d836 100644 --- a/packages/cli/src/commands/compare.ts +++ b/packages/cli/src/commands/compare.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -9,7 +10,7 @@ export default defineCommand({ }, async run({ args }) { const cache = await ensureCache(); - const rows = await cache.comparePrices(args.keyword, args.sites?.split(',')); + const rows = await cache.comparePrices(args.keyword, parseCsvArg(args.sites)); printTable(rows as unknown as Record[], { title: `Price Comparison: "${args.keyword}"`, }); diff --git a/packages/cli/src/commands/deals.ts b/packages/cli/src/commands/deals.ts index cf2929417d..66003374a5 100644 --- a/packages/cli/src/commands/deals.ts +++ b/packages/cli/src/commands/deals.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg, parseNonNegativeNumberArg, parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -11,10 +12,11 @@ export default defineCommand({ }, async run({ args }) { const cache = await ensureCache(); - const rows = await cache.findDeals(Number(args['max-price']), { + const maxPrice = parseNonNegativeNumberArg(args['max-price'], '--max-price'); + const rows = await cache.findDeals(maxPrice, { category: args.category, - sites: args.sites?.split(','), - limit: Number(args.limit), + sites: parseCsvArg(args.sites), + limit: parsePositiveIntArg(args.limit, '--limit'), }); printTable( rows.map(({ site, name, brand, price, category }) => ({ @@ -25,7 +27,7 @@ export default defineCommand({ category, })), { - title: `Deals under $${args['max-price']}`, + title: `Deals under $${maxPrice}`, }, ); }, diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 64f084a2af..daedd6d204 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -1,6 +1,7 @@ import { DataExporter } from '@packrat/analytics'; import { defineCommand } from 'citty'; import consola from 'consola'; +import { parseOptionalNumberArg } from '../args'; import { ensureCache, printSummary } from '../shared'; export default defineCommand({ @@ -44,7 +45,7 @@ export default defineCommand({ const summary = await exporter.export({ format, outputDir: args['output-dir'], - sample: args.sample ? Number(args.sample) : undefined, + sample: parseOptionalNumberArg(args.sample, '--sample'), dedup, includeQuality: args.quality ?? dedup !== 'none', skuFilter: args.sku, diff --git a/packages/cli/src/commands/filter.ts b/packages/cli/src/commands/filter.ts index 6e73abd0ec..e3f6e7110b 100644 --- a/packages/cli/src/commands/filter.ts +++ b/packages/cli/src/commands/filter.ts @@ -1,6 +1,7 @@ import { SpecParser } from '@packrat/analytics'; import { defineCommand } from 'citty'; -import { getCache, printTable } from '../shared'; +import { parseNonNegativeNumberArg, parseOptionalNumberArg, parsePositiveIntArg } from '../args'; +import { ensureCache, printTable } from '../shared'; export default defineCommand({ meta: { name: 'filter', description: 'Multi-attribute product filter' }, @@ -16,7 +17,7 @@ export default defineCommand({ limit: { type: 'string', alias: 'l', description: 'Result limit', default: '20' }, }, async run({ args }) { - const cache = await getCache(); + const cache = await ensureCache(); const conn = cache.getConnection(); const sortMap: Record = { @@ -28,14 +29,18 @@ export default defineCommand({ const parser = new SpecParser(conn); const rows = await parser.filterProducts({ category: args.category, - maxWeightG: args['max-weight'] ? Number(args['max-weight']) : undefined, - maxTempF: args['max-temp'] ? Number(args['max-temp']) : undefined, - maxPrice: args['max-price'] ? Number(args['max-price']) : undefined, - minPrice: args['min-price'] ? Number(args['min-price']) : undefined, + maxWeightG: args['max-weight'] + ? parseNonNegativeNumberArg(args['max-weight'], '--max-weight') + : undefined, + maxTempF: args['max-temp'] + ? parseOptionalNumberArg(args['max-temp'], '--max-temp') + : undefined, + maxPrice: parseOptionalNumberArg(args['max-price'], '--max-price'), + minPrice: parseOptionalNumberArg(args['min-price'], '--min-price'), gender: args.gender, seasons: args.seasons, sortBy: sortMap[args.sort] ?? 'price', - limit: Number(args.limit), + limit: parsePositiveIntArg(args.limit, '--limit'), }); printTable( rows.map(({ name, brand, price, weight_grams, temp_rating_f, seasons, gender }) => ({ diff --git a/packages/cli/src/commands/images.ts b/packages/cli/src/commands/images.ts index 38554d42c7..3a2eaa2d68 100644 --- a/packages/cli/src/commands/images.ts +++ b/packages/cli/src/commands/images.ts @@ -1,6 +1,7 @@ import { Enrichment } from '@packrat/analytics'; import { defineCommand } from 'citty'; import consola from 'consola'; +import { parsePositiveIntArg } from '../args'; import { ensureCache, printSummary, printTable } from '../shared'; export default defineCommand({ @@ -13,6 +14,7 @@ export default defineCommand({ async run({ args }) { const cache = await ensureCache(); const conn = cache.getConnection(); + const limit = parsePositiveIntArg(args.limit, '--limit'); const enrichment = new Enrichment(conn); @@ -35,7 +37,7 @@ export default defineCommand({ return; } - const images = await enrichment.getProductImages(args.product, Number(args.limit)); + const images = await enrichment.getProductImages(args.product, limit); if (images.length === 0) { consola.warn('No images found. Run `packrat images --build` first.'); return; diff --git a/packages/cli/src/commands/lightweight.ts b/packages/cli/src/commands/lightweight.ts index a204c34f95..2b9af32bea 100644 --- a/packages/cli/src/commands/lightweight.ts +++ b/packages/cli/src/commands/lightweight.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg, parseNonNegativeNumberArg, parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -16,11 +17,12 @@ export default defineCommand({ }, async run({ args }) { const cache = await ensureCache(); + const maxWeight = parseNonNegativeNumberArg(args['max-weight'], '--max-weight'); const rows = await cache.findLightweight({ category: args.category, - maxWeightG: Number(args['max-weight']), - sites: args.sites?.split(','), - limit: Number(args.limit), + maxWeightG: maxWeight, + sites: parseCsvArg(args.sites), + limit: parsePositiveIntArg(args.limit, '--limit'), }); printTable( rows.map(({ site, name, brand, weight_g, price, weight_per_dollar }) => ({ @@ -31,7 +33,7 @@ export default defineCommand({ price, 'g/$': weight_per_dollar, })), - { title: `Lightweight Gear (≤${args['max-weight']}g)` }, + { title: `Lightweight Gear (≤${maxWeight}g)` }, ); }, }); diff --git a/packages/cli/src/commands/market-share.ts b/packages/cli/src/commands/market-share.ts index 4819f9a015..69330c2f85 100644 --- a/packages/cli/src/commands/market-share.ts +++ b/packages/cli/src/commands/market-share.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -11,7 +12,7 @@ export default defineCommand({ const cache = await ensureCache(); const rows = await cache.getMarketShare({ category: args.category, - topN: Number(args.top), + topN: parsePositiveIntArg(args.top, '--top'), }); printTable(rows as unknown as Record[], { title: 'Market Share' }); }, diff --git a/packages/cli/src/commands/ratings.ts b/packages/cli/src/commands/ratings.ts index dc24289e34..b42d008165 100644 --- a/packages/cli/src/commands/ratings.ts +++ b/packages/cli/src/commands/ratings.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg, parseNonNegativeNumberArg, parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -13,9 +14,9 @@ export default defineCommand({ const cache = await ensureCache(); const rows = await cache.getTopRated({ category: args.category, - minReviews: Number(args['min-reviews']), - sites: args.sites?.split(','), - limit: Number(args.limit), + minReviews: parseNonNegativeNumberArg(args['min-reviews'], '--min-reviews'), + sites: parseCsvArg(args.sites), + limit: parsePositiveIntArg(args.limit, '--limit'), }); printTable( rows.map(({ site, name, brand, rating_value, review_count, price, score }) => ({ diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index a28ba5943c..72c14c11d4 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -1,6 +1,7 @@ import { EntityResolver } from '@packrat/analytics'; import { defineCommand } from 'citty'; import consola from 'consola'; +import { parseConfidenceArg, parsePositiveIntArg } from '../args'; import { ensureCache, printSummary, printTable } from '../shared'; export default defineCommand({ @@ -26,12 +27,14 @@ export default defineCommand({ async run({ args }) { const cache = await ensureCache(); const conn = cache.getConnection(); + const limit = parsePositiveIntArg(args.limit, '--limit'); const resolver = new EntityResolver(conn); if (args.build) { + const minConfidence = parseConfidenceArg(args['min-confidence'], '--min-confidence'); consola.start('Running entity resolution (this may take a while)...'); - const stats = await resolver.build(Number(args['min-confidence'])); + const stats = await resolver.build(minConfidence); printSummary( { 'Total listings': stats.total, @@ -48,7 +51,7 @@ export default defineCommand({ return; } - const matches = await resolver.identifyProduct(args.product, Number(args.limit)); + const matches = await resolver.identifyProduct(args.product, limit); if (matches.length === 0) { consola.warn('No matches. Run `packrat resolve --build` first.'); return; diff --git a/packages/cli/src/commands/reviews.ts b/packages/cli/src/commands/reviews.ts index c27f54ee52..8fe34f4665 100644 --- a/packages/cli/src/commands/reviews.ts +++ b/packages/cli/src/commands/reviews.ts @@ -1,6 +1,7 @@ import { Enrichment } from '@packrat/analytics'; import { defineCommand } from 'citty'; import consola from 'consola'; +import { parsePositiveIntArg } from '../args'; import { ensureCache, printSummary, printTable } from '../shared'; export default defineCommand({ @@ -13,6 +14,7 @@ export default defineCommand({ async run({ args }) { const cache = await ensureCache(); const conn = cache.getConnection(); + const limit = parsePositiveIntArg(args.limit, '--limit'); const enrichment = new Enrichment(conn); @@ -36,7 +38,7 @@ export default defineCommand({ return; } - const reviews = await enrichment.getProductReviews(args.product, Number(args.limit)); + const reviews = await enrichment.getProductReviews(args.product, limit); if (reviews.length === 0) { consola.warn('No reviews found. Run `packrat reviews --build` first.'); return; diff --git a/packages/cli/src/commands/sales.ts b/packages/cli/src/commands/sales.ts index 2f51066121..5b2e877426 100644 --- a/packages/cli/src/commands/sales.ts +++ b/packages/cli/src/commands/sales.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg, parsePercentageArg, parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -12,10 +13,10 @@ export default defineCommand({ async run({ args }) { const cache = await ensureCache(); const rows = await cache.findSales({ - minDiscountPct: Number(args['min-discount']), + minDiscountPct: parsePercentageArg(args['min-discount'], '--min-discount'), category: args.category, - sites: args.sites?.split(','), - limit: Number(args.limit), + sites: parseCsvArg(args.sites), + limit: parsePositiveIntArg(args.limit, '--limit'), }); printTable( rows.map(({ site, name, price, compare_at_price, discount_pct }) => ({ diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 123535d537..6c78695cc4 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parseCsvArg, parseOptionalNumberArg, parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -12,11 +13,12 @@ export default defineCommand({ }, async run({ args }) { const cache = await ensureCache(); + const limit = parsePositiveIntArg(args.limit, '--limit'); const rows = await cache.search(args.keyword, { - maxPrice: args['max-price'] ? Number(args['max-price']) : undefined, - minPrice: args['min-price'] ? Number(args['min-price']) : undefined, - sites: args.sites?.split(','), - limit: Number(args.limit), + maxPrice: parseOptionalNumberArg(args['max-price'], '--max-price'), + minPrice: parseOptionalNumberArg(args['min-price'], '--min-price'), + sites: parseCsvArg(args.sites), + limit, }); printTable( rows.map(({ site, name, brand, price }) => ({ site, name: name.slice(0, 50), brand, price })), diff --git a/packages/cli/src/commands/specs.ts b/packages/cli/src/commands/specs.ts index 979aad3a5c..90279cf33b 100644 --- a/packages/cli/src/commands/specs.ts +++ b/packages/cli/src/commands/specs.ts @@ -1,6 +1,7 @@ import { SpecParser } from '@packrat/analytics'; import { defineCommand } from 'citty'; -import { getCache, printTable } from '../shared'; +import { parsePositiveIntArg } from '../args'; +import { ensureCache, printTable } from '../shared'; export default defineCommand({ meta: { name: 'specs', description: 'View parsed specs for a product' }, @@ -9,11 +10,14 @@ export default defineCommand({ limit: { type: 'string', alias: 'l', description: 'Result limit', default: '10' }, }, async run({ args }) { - const cache = await getCache(); + const cache = await ensureCache(); const conn = cache.getConnection(); const parser = new SpecParser(conn); - const rows = await parser.getProductSpecs(args.product, Number(args.limit)); + const rows = await parser.getProductSpecs( + args.product, + parsePositiveIntArg(args.limit, '--limit'), + ); printTable( rows.map( ({ diff --git a/packages/cli/src/commands/trends.ts b/packages/cli/src/commands/trends.ts index 0d064d511e..a73ebb7875 100644 --- a/packages/cli/src/commands/trends.ts +++ b/packages/cli/src/commands/trends.ts @@ -1,4 +1,5 @@ import { defineCommand } from 'citty'; +import { parsePositiveIntArg } from '../args'; import { ensureCache, printTable } from '../shared'; export default defineCommand({ @@ -9,10 +10,7 @@ export default defineCommand({ site: { type: 'string', alias: 's', description: 'Filter to specific site' }, }, async run({ args }) { - const days = Number.parseInt(String(args.days), 10); - if (!Number.isFinite(days) || days <= 0) { - throw new Error(`Invalid --days value: "${args.days}". Must be a positive integer.`); - } + const days = parsePositiveIntArg(args.days, '--days'); const cache = await ensureCache(); const rows = await cache.searchTrends(args.keyword, { site: args.site, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts old mode 100644 new mode 100755 index 0cd4d1a6d1..43ce9a9c7f --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,16 +1,44 @@ #!/usr/bin/env bun + /** * PackRat Analytics CLI — outdoor gear market intelligence. * * Built with citty (UnJS) for modern CLI ergonomics. */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { nodeEnv } from '@packrat/env/node'; import { defineCommand, runMain } from 'citty'; +import consola from 'consola'; +import { z } from 'zod'; + +const packageVersionSchema = z.object({ + version: z.string().min(1), +}); + +function getCliVersion(): string { + try { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const packageJsonPath = resolve(currentDir, '../package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as unknown; + const parsed = packageVersionSchema.safeParse(packageJson); + if (!parsed.success) { + consola.warn('package.json is missing a valid string "version" field.'); + return '0.0.0'; + } + return parsed.data.version; + } catch (error) { + consola.warn(`Unable to determine CLI version from package.json: ${String(error)}`); + return '0.0.0'; + } +} const main = defineCommand({ meta: { name: 'packrat', - version: '0.1.0', + version: getCliVersion(), description: 'Outdoor gear analytics powered by DuckDB', }, subCommands: { @@ -57,4 +85,14 @@ const main = defineCommand({ }, }); -runMain(main); +runMain(main).catch((error: unknown) => { + if (error instanceof Error) { + consola.error(error.message); + if (nodeEnv.DEBUG) { + consola.error(error.stack ?? '(no stack trace)'); + } + } else { + consola.error(String(error)); + } + process.exitCode = 1; +}); diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000000..28a2057d9f --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@packrat/config", + "version": "2.0.21", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./config": "./src/config.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts" +} diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts new file mode 100644 index 0000000000..c6e6303cea --- /dev/null +++ b/packages/config/src/config.ts @@ -0,0 +1,114 @@ +const GAP_PREFIX = 'gap '; + +const FeatureFlag = Object.freeze({ + EnableOAuth: 'enableOAuth', + EnableTrips: 'enableTrips', + EnablePackInsights: 'enablePackInsights', + EnableShoppingList: 'enableShoppingList', + EnableSharedPacks: 'enableSharedPacks', + EnablePackTemplates: 'enablePackTemplates', + EnableTrailConditions: 'enableTrailConditions', + EnableFeed: 'enableFeed', + EnableWildlifeIdentification: 'enableWildlifeIdentification', + EnableLocalAI: 'enableLocalAI', +}); + +const DashboardTileId = Object.freeze({ + CurrentPack: 'current-pack', + RecentPacks: 'recent-packs', + SeasonSuggestions: 'season-suggestions', + AskPackRatAi: 'ask-packrat-ai', + ReportedAiContent: 'reported-ai-content', + AiPacks: 'ai-packs', + PackStats: 'pack-stats', + WeightAnalysis: 'weight-analysis', + PackCategories: 'pack-categories', + UpcomingTrips: 'upcoming-trips', + TrailConditions: 'trail-conditions', + Weather: 'weather', + WeatherAlerts: 'weather-alerts', + GearInventory: 'gear-inventory', + ShoppingList: 'shopping-list', + SharedPacks: 'shared-packs', + PackTemplates: 'pack-templates', + Feed: 'feed', + Guides: 'guides', + Wildlife: 'wildlife', +}); + +const DashboardLayoutId = Object.freeze({ + Gap1: `${GAP_PREFIX}1`, + Gap15: `${GAP_PREFIX}1.5`, + Gap2: `${GAP_PREFIX}2`, + Gap25: `${GAP_PREFIX}2.5`, + Gap3: `${GAP_PREFIX}3`, + Gap4: `${GAP_PREFIX}4`, +}); + +function deepFreeze(value: T): Readonly { + if (value === null || typeof value !== 'object') return value; + if (Object.isFrozen(value)) return value; + + const record = value as Record; + for (const nestedValue of Object.values(record)) { + deepFreeze(nestedValue); + } + + return Object.freeze(value); +} + +const APP_CONFIG_SOURCE = { + featureFlags: { + [FeatureFlag.EnableOAuth]: true, + [FeatureFlag.EnableTrips]: true, + [FeatureFlag.EnablePackInsights]: false, + [FeatureFlag.EnableShoppingList]: false, + [FeatureFlag.EnableSharedPacks]: false, + [FeatureFlag.EnablePackTemplates]: true, + [FeatureFlag.EnableTrailConditions]: false, + [FeatureFlag.EnableFeed]: false, + [FeatureFlag.EnableWildlifeIdentification]: false, + [FeatureFlag.EnableLocalAI]: false, + }, + dashboard: { + gapPrefix: GAP_PREFIX, + strings: { + searchPlaceholder: 'Search...', + resultSingular: 'result', + resultPlural: 'results', + }, + layout: { + base: [ + DashboardTileId.CurrentPack, + DashboardTileId.RecentPacks, + DashboardTileId.SeasonSuggestions, + DashboardLayoutId.Gap1, + DashboardTileId.AskPackRatAi, + DashboardTileId.ReportedAiContent, + DashboardTileId.AiPacks, + DashboardLayoutId.Gap15, + DashboardTileId.PackStats, + DashboardTileId.WeightAnalysis, + DashboardTileId.PackCategories, + ], + weatherSection: [DashboardLayoutId.Gap25, DashboardTileId.Weather], + gearSection: [DashboardLayoutId.Gap3, DashboardTileId.GearInventory], + footerSection: [DashboardLayoutId.Gap4, DashboardTileId.Guides], + conditional: { + tripsOrTrailSpacer: DashboardLayoutId.Gap2, + trips: DashboardTileId.UpcomingTrips, + trailConditions: DashboardTileId.TrailConditions, + weatherAlerts: DashboardTileId.WeatherAlerts, + shoppingList: DashboardTileId.ShoppingList, + sharedPacks: DashboardTileId.SharedPacks, + packTemplates: DashboardTileId.PackTemplates, + feed: DashboardTileId.Feed, + wildlife: DashboardTileId.Wildlife, + }, + }, + }, +} as const; + +const APP_CONFIG = deepFreeze(APP_CONFIG_SOURCE); + +export { APP_CONFIG, DashboardLayoutId, DashboardTileId, FeatureFlag }; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 0000000000..e78c691e55 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1 @@ +export { APP_CONFIG, DashboardLayoutId, DashboardTileId, FeatureFlag } from './config'; diff --git a/packages/env/package.json b/packages/env/package.json new file mode 100644 index 0000000000..9c4ea9dd53 --- /dev/null +++ b/packages/env/package.json @@ -0,0 +1,18 @@ +{ + "name": "@packrat/env", + "version": "2.0.21", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./node": "./src/node.ts", + "./next": "./src/next.ts", + "./expo-client": "./src/expo-client.ts", + "./expo-server": "./src/expo-server.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "zod": "catalog:" + } +} diff --git a/packages/env/scripts/no-raw-process-env.ts b/packages/env/scripts/no-raw-process-env.ts new file mode 100644 index 0000000000..2ea22c1067 --- /dev/null +++ b/packages/env/scripts/no-raw-process-env.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env bun +// +// no-raw-process-env.ts — flags raw `process.env` access outside of allowed files. +// +// Node/Bun scripts should import `nodeEnv` from `@packrat/env/node` instead of +// reading `process.env.*` directly. This script detects violations and lists +// them so they can be migrated. +// +// Allowed files (intentionally exempt — see packages/env/src/node.ts for rationale): +// - packages/env/src/node.ts — the Node shim itself +// - packages/env/src/next.ts — the Next.js shim itself +// - packages/env/src/expo-client.ts — the Expo client shim itself +// - packages/env/src/expo-server.ts — the Expo server shim itself +// - .github/scripts/configure-deps.ts — preinstall hook, runs before node_modules +// - .github/scripts/env.ts — postinstall hook, runs before node_modules +// - packages/api/src/utils/env-validation.ts — Cloudflare Worker runtime (uses c.env) +// - apps/expo/app.config.ts — Expo config, build-time only +// +// Exit code: +// 0 — no violations +// 1 — violations found + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOT = join(import.meta.dir, '..', '..', '..'); + +// Directories to scan +const SCAN_ROOTS = ['packages', 'apps', '.github/scripts']; + +// Files / path prefixes that are explicitly exempt +const ALLOWED: string[] = [ + 'packages/env/src/node.ts', + 'packages/env/src/next.ts', + 'packages/env/src/expo-client.ts', + 'packages/env/src/expo-server.ts', + '.github/scripts/configure-deps.ts', + '.github/scripts/env.ts', + 'packages/api/src/utils/env-validation.ts', + 'packages/api/container_src/server.ts', + 'packages/analytics/test/core/env.test.ts', + 'apps/expo/app.config.ts', +]; + +// Directories to skip entirely +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'build', '.expo', '.next', '.wrangler']); + +// Matches process.env.FOO or process.env["FOO"] or bare process.env +const PROCESS_ENV_RE = /\bprocess\.env\b/; + +const TARGET_FILE_RE = /\.(ts|tsx|cts|mts|js|mjs|cjs)$/; + +function isAllowed(relPath: string): boolean { + return ALLOWED.some((allowed) => + allowed.endsWith('/') ? relPath.startsWith(allowed) : relPath === allowed, + ); +} + +function isTargetFile(name: string): boolean { + return TARGET_FILE_RE.test(name); +} + +interface Violation { + file: string; + line: number; + content: string; +} + +const violations: Violation[] = []; + +function walkDir(dir: string, relPath: string): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry)) continue; + + const entryFull = join(dir, entry); + const entryRel = relPath ? `${relPath}/${entry}` : entry; + + let isDir = false; + try { + isDir = statSync(entryFull).isDirectory(); + } catch { + continue; + } + + if (isDir) { + walkDir(entryFull, entryRel); + } else if (isTargetFile(entry)) { + if (isAllowed(entryRel)) continue; + + let content: string; + try { + content = readFileSync(entryFull, 'utf-8'); + } catch { + continue; + } + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (PROCESS_ENV_RE.test(lines[i] ?? '')) { + violations.push({ file: entryRel, line: i + 1, content: lines[i]?.trimEnd() ?? '' }); + } + } + } + } +} + +for (const root of SCAN_ROOTS) { + const absRoot = join(ROOT, root); + // For .github/scripts we use the relative path directly + const relRoot = relative(ROOT, absRoot); + walkDir(absRoot, relRoot); +} + +if (violations.length > 0) { + console.log( + `Raw process.env access found (${violations.length} occurrence${violations.length === 1 ? '' : 's'}).\n` + + `Migrate Node/Bun scripts to import nodeEnv from '@packrat/env/node'.\n` + + `Other runtimes (Expo, Next.js, Cloudflare Worker) have separate shims.\n`, + ); + for (const { file, line, content } of violations) { + console.log(` ${file}:${line} ${content.trimStart()}`); + } + process.exit(1); +} + +console.log('No raw process.env access found outside of allowed files.'); diff --git a/packages/env/src/expo-client.ts b/packages/env/src/expo-client.ts new file mode 100644 index 0000000000..4f8f1955aa --- /dev/null +++ b/packages/env/src/expo-client.ts @@ -0,0 +1,44 @@ +/** + * Expo client-side environment shim for `apps/expo`. + * + * Parses `EXPO_PUBLIC_*` variables (and `NODE_ENV`) at module load using Zod + * and exports the typed result as `clientEnvs`. Follows the T3-Env pattern for + * Expo: all `process.env.*` accesses are explicit so Metro can inline them at + * build time. + * + * NOTE: Only `EXPO_PUBLIC_*` variables are available in the client bundle at + * runtime — Metro strips all other `process.env.*` accesses. Server-only vars + * (e.g. `OPENAI_API_KEY`) belong in `@packrat/env/expo-server`. + */ + +import { z } from 'zod'; + +export const clientEnvSchema = z.object({ + NODE_ENV: z.enum(['development', 'production']).default('production'), + EXPO_PUBLIC_API_URL: z.string().url(), + EXPO_PUBLIC_R2_PUBLIC_URL: z.string().url(), + EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: z.string(), + EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: z.string(), + EXPO_PUBLIC_SENTRY_DSN: z.string().optional(), + EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: z.string().optional(), +}); + +export type ClientEnv = z.infer; + +// Explicit process.env access is required so Metro can statically inline values. +const processEnv = { + NODE_ENV: process.env.NODE_ENV, + EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL, + EXPO_PUBLIC_R2_PUBLIC_URL: process.env.EXPO_PUBLIC_R2_PUBLIC_URL, + EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: process.env.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, + EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, + EXPO_PUBLIC_SENTRY_DSN: process.env.EXPO_PUBLIC_SENTRY_DSN, + EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY, +}; + +/** + * Typed env parsed from `process.env` at module load. Throws a Zod + * validation error if any required value is missing or fails its schema + * constraint. + */ +export const clientEnvs = clientEnvSchema.parse(processEnv); diff --git a/packages/env/src/expo-server.ts b/packages/env/src/expo-server.ts new file mode 100644 index 0000000000..517d871bb4 --- /dev/null +++ b/packages/env/src/expo-server.ts @@ -0,0 +1,29 @@ +/** + * Expo server-side environment shim for `apps/expo`. + * + * Parses server-only variables at module load using Zod and exports the + * typed result as `serverEnv`. These variables are NOT available in the + * client bundle — only use this in Expo API routes or server functions. + * + * NOTE: This file must NOT be imported in client-side components. + * Client-side variables (EXPO_PUBLIC_*) belong in `@packrat/env/expo-client`. + */ + +import { z } from 'zod'; + +export const serverEnvSchema = z.object({ + OPENAI_API_KEY: z.string(), +}); + +export type ServerEnv = z.infer; + +const processEnv = { + OPENAI_API_KEY: process.env.OPENAI_API_KEY, +}; + +/** + * Typed env parsed from `process.env` at module load. Throws a Zod + * validation error if any required value is missing or fails its schema + * constraint. + */ +export const serverEnv = serverEnvSchema.parse(processEnv); diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts new file mode 100644 index 0000000000..c0d5ff0baf --- /dev/null +++ b/packages/env/src/index.ts @@ -0,0 +1,17 @@ +/** + * @packrat/env — typed, Zod-validated environment variable shims. + * + * Runtime-specific entry points: + * - `@packrat/env/node` — Node/Bun scripts (default export at root is + * the Node shim for convenience; prefer the explicit `/node` path + * in new code). + * - `@packrat/env/next` — Next.js apps (`apps/guides`, `apps/landing`). + * - `@packrat/env/expo-client` — Expo client bundle (`EXPO_PUBLIC_*` vars). + * - `@packrat/env/expo-server` — Expo server-only vars (API routes, etc.). + * + * Not yet covered here: + * - Cloudflare Worker API — see `packages/api/src/utils/env-validation.ts` + */ + +export type { NodeEnv } from './node'; +export { nodeEnv, nodeEnvSchema } from './node'; diff --git a/packages/env/src/next.ts b/packages/env/src/next.ts new file mode 100644 index 0000000000..c8ce42fe73 --- /dev/null +++ b/packages/env/src/next.ts @@ -0,0 +1,27 @@ +/** + * Next.js environment shim for Next.js apps (`apps/guides`, `apps/landing`). + * Parses `process.env` once at module load using Zod and exports the typed + * result as `guideEnv`. + * + * NOTE: `process.env.NODE_ENV` is statically replaced by Next.js at build + * time, so it is safe to include here. Server-only vars (e.g. `PACKRAT_API_KEY`) + * are optional — callers must check for presence before using. + * + * Adding a new variable: declare it on `guideEnvSchema`, mark it + * `.optional()` unless every caller genuinely requires it. + */ + +import { z } from 'zod'; + +export const guideEnvSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PACKRAT_API_KEY: z.string().optional(), +}); + +export type GuideEnv = z.infer; + +/** + * Typed env parsed from `process.env` at module load. Throws a Zod + * validation error if any value fails its schema constraint. + */ +export const guideEnv = guideEnvSchema.parse(process.env); diff --git a/packages/env/src/node.ts b/packages/env/src/node.ts new file mode 100644 index 0000000000..c59b6f4b6d --- /dev/null +++ b/packages/env/src/node.ts @@ -0,0 +1,73 @@ +/** + * Node-runtime environment shim for Node/Bun scripts (analytics CLI, + * API scripts). Parses `process.env` once at module load using Zod and + * exports the typed result as `env`. + * + * NOTE: this shim is intentionally scoped to Node/Bun scripts. It does + * NOT cover: + * - The Cloudflare Worker API (see + * `packages/api/src/utils/env-validation.ts` — that uses `c.env` via + * Hono, not `process.env`). + * - Bootstrap/preinstall scripts (e.g. `.github/scripts/configure-deps.ts`) + * that run before workspace packages are available — those must read + * `process.env` directly. + * + * Adding a new variable: declare it on `nodeEnvSchema`, mark it + * `.optional()` unless every caller genuinely requires it, and prefer + * narrow types (enums, `.url()`, etc.) over raw `z.string()`. + */ + +import { z } from 'zod'; + +/** + * Schema for variables commonly read from Node/Bun scripts in the + * monorepo. Keep this list deliberately small — only add variables + * that have at least one real caller in the repo. + */ +export const nodeEnvSchema = z.object({ + // ── Runtime / CI ────────────────────────────────────────────────── + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + CI: z.string().optional(), + GITHUB_ACTIONS: z.string().optional(), + + // ── Bun install auth (.github/scripts/configure-deps.ts) ────────── + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: z.string().min(1).optional(), + + // ── Neon / Postgres (packages/api/migrate.ts, seed.ts) ──────────── + NEON_DATABASE_URL: z.string().url().optional(), + NEON_DATABASE_URL_READONLY: z.string().url().optional(), + + // ── R2 / S3 credentials (packages/analytics/scripts/smoke-test.ts) ─ + R2_ACCESS_KEY_ID: z.string().min(1).optional(), + R2_SECRET_ACCESS_KEY: z.string().min(1).optional(), + R2_ENDPOINT_URL: z.string().url().optional(), + R2_BUCKET_NAME: z.string().min(1).optional(), + PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME: z.string().min(1).optional(), + PACKRAT_ITEMS_BUCKET_R2_BUCKET_NAME: z.string().min(1).optional(), + R2_CATALOG_TOKEN: z.string().min(1).optional(), + R2_CATALOG_URI: z.string().min(1).optional(), + R2_WAREHOUSE_NAME: z.string().min(1).optional(), + + // ── API container (packages/api/container_src/server.ts) ────────── + GOOGLE_GENAI_API_KEY: z.string().min(1).optional(), + CLOUDFLARE_CONTAINER_ID: z.string().optional(), + PORT: z.string().regex(/^\d+$/, 'PORT must be a numeric string').optional(), + + // ── Test runner flags ───────────────────────────────────────────── + VITEST: z.string().optional(), + + // ── Debug / verbose ─────────────────────────────────────────────── + DEBUG: z.string().optional(), + + // ── E2E test credentials ────────────────────────────────────────── + E2E_TEST_EMAIL: z.string().email().optional(), + E2E_TEST_PASSWORD: z.string().min(1).optional(), +}); + +export type NodeEnv = z.infer; + +/** + * Typed env parsed from `process.env` at module load. Throws a Zod + * validation error if any value fails its schema constraint. + */ +export const nodeEnv = nodeEnvSchema.parse(process.env); diff --git a/packages/guards/package.json b/packages/guards/package.json index e941325b3c..9aaa40d389 100644 --- a/packages/guards/package.json +++ b/packages/guards/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/guards", - "version": "2.0.20", + "version": "2.0.21", "private": true, "type": "module", "exports": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 9c4be29a99..0d1cf8fdd5 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/mcp", - "version": "2.0.20", + "version": "2.0.21", "private": true, "description": "PackRat MCP Server — outdoor adventure planning via Model Context Protocol", "scripts": { @@ -13,6 +13,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.11.0", + "@packrat/api-client": "workspace:*", "agents": "^0.11.0", "zod": "catalog:" }, diff --git a/packages/mcp/src/__tests__/auth.test.ts b/packages/mcp/src/__tests__/auth.test.ts index 2a617154bd..aa45534ca4 100644 --- a/packages/mcp/src/__tests__/auth.test.ts +++ b/packages/mcp/src/__tests__/auth.test.ts @@ -33,8 +33,11 @@ const fakeCtx = { // ── stub agents/mcp so we do NOT spin up a real Durable Object ─────────────── vi.mock('agents/mcp', () => { - // biome-ignore lint/complexity/noStaticOnlyClass: mock must be a class since PackRatMCP extends McpAgent class McpAgent { + // Instance fetch stub mirrors the real McpAgent shape (Durable Object) + 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 })), diff --git a/packages/mcp/src/__tests__/client.test.ts b/packages/mcp/src/__tests__/client.test.ts index 0edd301be3..2c763e55b5 100644 --- a/packages/mcp/src/__tests__/client.test.ts +++ b/packages/mcp/src/__tests__/client.test.ts @@ -24,7 +24,7 @@ describe('ok()', () => { describe('err()', () => { it('formats an ApiError with status code', () => { - const result = err(new ApiError('Not Found', 404, { error: 'Not Found' })); + 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'); }); @@ -47,7 +47,7 @@ describe('err()', () => { describe('ApiError', () => { it('sets name, status, and body', () => { const body = { error: 'Unauthorized' }; - const e = new ApiError('Unauthorized', 401, body); + const e = new ApiError('Unauthorized', { status: 401, body }); expect(e.name).toBe('ApiError'); expect(e.message).toBe('Unauthorized'); expect(e.status).toBe(401); diff --git a/packages/mcp/src/__tests__/tools/catalog.test.ts b/packages/mcp/src/__tests__/tools/catalog.test.ts index 357ec5aa12..35051d6345 100644 --- a/packages/mcp/src/__tests__/tools/catalog.test.ts +++ b/packages/mcp/src/__tests__/tools/catalog.test.ts @@ -33,7 +33,7 @@ describe('catalog tools', () => { query: 'ultralight tent', category: 'tents', limit: 5, - offset: 0, + page: 1, sort_by: 'price', sort_order: 'asc', }, @@ -43,23 +43,19 @@ describe('catalog tools', () => { q: 'ultralight tent', category: 'tents', limit: 5, - offset: 0, + 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', 500, {})); + vi.mocked(api.get).mockRejectedValue(new ApiError('Server Error', { status: 500, body: {} })); const result = await callTool({ tools, name: 'search_gear_catalog', - args: { - limit: 10, - offset: 0, - sort_order: 'asc', - }, + args: { limit: 10, page: 1, sort_order: 'asc' }, }); expect(result.isError).toBe(true); @@ -76,17 +72,12 @@ describe('catalog tools', () => { await callTool({ tools, name: 'semantic_gear_search', - args: { - query: 'warm puffy jacket for winter camping', - limit: 5, - offset: 0, - }, + 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, - offset: 0, }); }); @@ -97,11 +88,7 @@ describe('catalog tools', () => { const result = await callTool({ tools, name: 'semantic_gear_search', - args: { - query: 'midlayer fleece', - limit: 8, - offset: 0, - }, + args: { query: 'midlayer fleece', limit: 8 }, }); expect(parseToolResult(result)).toEqual({ items }); @@ -122,7 +109,7 @@ describe('catalog tools', () => { }); it('propagates 404 as error result', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', 404, {})); + 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 } }); @@ -199,7 +186,7 @@ describe('catalog tools', () => { }); it('returns error result if any item fetch fails', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', 404, {})); + vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', { status: 404, body: {} })); const result = await callTool({ tools, diff --git a/packages/mcp/src/__tests__/tools/knowledge.test.ts b/packages/mcp/src/__tests__/tools/knowledge.test.ts index 771337b17c..1ad7fa0678 100644 --- a/packages/mcp/src/__tests__/tools/knowledge.test.ts +++ b/packages/mcp/src/__tests__/tools/knowledge.test.ts @@ -44,7 +44,9 @@ describe('knowledge tools', () => { }); it('returns error result on API failure', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Service Unavailable', 503, {})); + vi.mocked(api.get).mockRejectedValue( + new ApiError('Service Unavailable', { status: 503, body: {} }), + ); const result = await callTool({ tools, @@ -121,7 +123,9 @@ describe('knowledge tools', () => { }); it('returns error for failed queries', async () => { - vi.mocked(api.post).mockRejectedValue(new ApiError('Syntax error in SQL', 400, {})); + vi.mocked(api.post).mockRejectedValue( + new ApiError('Syntax error in SQL', { status: 400, body: {} }), + ); const result = await callTool({ tools, diff --git a/packages/mcp/src/__tests__/tools/packs.test.ts b/packages/mcp/src/__tests__/tools/packs.test.ts index e8fa0dc920..a4951119a9 100644 --- a/packages/mcp/src/__tests__/tools/packs.test.ts +++ b/packages/mcp/src/__tests__/tools/packs.test.ts @@ -23,32 +23,20 @@ describe('pack tools', () => { expect(tools.has('list_packs')).toBe(true); }); - it('calls GET /packs with limit, offset, category', async () => { + 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: { - limit: 5, - offset: 10, - category: 'backpacking', - }, - }); + const result = await callTool({ tools, name: 'list_packs', args: { include_public: true } }); - expect(api.get).toHaveBeenCalledWith('/packs', { - limit: 5, - offset: 10, - category: 'backpacking', - }); + 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', 403, {})); + vi.mocked(api.get).mockRejectedValue(new ApiError('Forbidden', { status: 403, body: {} })); - const result = await callTool({ tools, name: 'list_packs', args: { limit: 20, offset: 0 } }); + const result = await callTool({ tools, name: 'list_packs', args: {} }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('403'); @@ -69,7 +57,7 @@ describe('pack tools', () => { }); it('propagates 404 as error result', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', 404, {})); + vi.mocked(api.get).mockRejectedValue(new ApiError('Not Found', { status: 404, body: {} })); const result = await callTool({ tools, name: 'get_pack', args: { pack_id: 'nope' } }); @@ -112,8 +100,8 @@ describe('pack tools', () => { // ── update_pack ───────────────────────────────────────────────────────────── describe('update_pack', () => { - it('calls PATCH /packs/:id with only provided fields', async () => { - vi.mocked(api.patch).mockResolvedValue({ id: 'p_1' }); + it('calls PUT /packs/:id with only provided fields', async () => { + vi.mocked(api.put).mockResolvedValue({ id: 'p_1' }); await callTool({ tools, @@ -125,7 +113,7 @@ describe('pack tools', () => { }, }); - const [path, body] = vi.mocked(api.patch).mock.calls[0] as [string, Record]; + 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); @@ -133,11 +121,11 @@ describe('pack tools', () => { }); it('does not include undefined optional fields', async () => { - vi.mocked(api.patch).mockResolvedValue({ id: 'p_1' }); + 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.patch).mock.calls[0] as [string, Record]; + const [, body] = vi.mocked(api.put).mock.calls[0] as [string, Record]; expect(body.description).toBeUndefined(); expect(body.tags).toBeUndefined(); }); @@ -189,16 +177,12 @@ describe('pack tools', () => { // ── remove_pack_item ──────────────────────────────────────────────────────── describe('remove_pack_item', () => { - it('calls DELETE /packs/:id/items/:itemId', async () => { + it('calls DELETE /packs/items/:itemId', async () => { vi.mocked(api.delete).mockResolvedValue({ deleted: true }); - await callTool({ - tools, - name: 'remove_pack_item', - args: { pack_id: 'p_1', item_id: 'i_99' }, - }); + await callTool({ tools, name: 'remove_pack_item', args: { item_id: 'i_99' } }); - expect(api.delete).toHaveBeenCalledWith('/packs/p_1/items/i_99'); + expect(api.delete).toHaveBeenCalledWith('/packs/items/i_99'); }); }); diff --git a/packages/mcp/src/__tests__/tools/trail-conditions.test.ts b/packages/mcp/src/__tests__/tools/trail-conditions.test.ts index ec04bafa15..d0dc1e7dfc 100644 --- a/packages/mcp/src/__tests__/tools/trail-conditions.test.ts +++ b/packages/mcp/src/__tests__/tools/trail-conditions.test.ts @@ -54,7 +54,9 @@ describe('trail condition tools', () => { }); it('returns error result on API failure', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Internal Server Error', 500, {})); + 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 } }); @@ -129,7 +131,9 @@ describe('trail condition tools', () => { }); it('returns error when user is not authenticated (401)', async () => { - vi.mocked(api.post).mockRejectedValue(new ApiError('Unauthorized', 401, {})); + vi.mocked(api.post).mockRejectedValue( + new ApiError('Unauthorized', { status: 401, body: {} }), + ); const result = await callTool({ tools, diff --git a/packages/mcp/src/__tests__/tools/trips.test.ts b/packages/mcp/src/__tests__/tools/trips.test.ts index 07749b12b3..80ef35ce42 100644 --- a/packages/mcp/src/__tests__/tools/trips.test.ts +++ b/packages/mcp/src/__tests__/tools/trips.test.ts @@ -23,18 +23,18 @@ describe('trip tools', () => { expect(tools.has('list_trips')).toBe(true); }); - it('calls GET /trips with limit and offset', async () => { + it('calls GET /trips with includePublic param', async () => { vi.mocked(api.get).mockResolvedValue({ items: [] }); - await callTool({ tools, name: 'list_trips', args: { limit: 10, offset: 20 } }); + await callTool({ tools, name: 'list_trips', args: { include_public: true } }); - expect(api.get).toHaveBeenCalledWith('/trips', { limit: 10, offset: 20 }); + 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: { limit: 20, offset: 0 } }); + const result = await callTool({ tools, name: 'list_trips', args: {} }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Network error'); @@ -171,7 +171,7 @@ describe('trip tools', () => { }); it('returns error when API fails', async () => { - vi.mocked(api.delete).mockRejectedValue(new ApiError('Forbidden', 403, {})); + vi.mocked(api.delete).mockRejectedValue(new ApiError('Forbidden', { status: 403, body: {} })); const result = await callTool({ tools, name: 'delete_trip', args: { trip_id: 't_x' } }); diff --git a/packages/mcp/src/__tests__/tools/weather.test.ts b/packages/mcp/src/__tests__/tools/weather.test.ts index 451691320f..b25234cef6 100644 --- a/packages/mcp/src/__tests__/tools/weather.test.ts +++ b/packages/mcp/src/__tests__/tools/weather.test.ts @@ -59,7 +59,7 @@ describe('weather tools', () => { }); it('returns error result when search API fails', async () => { - vi.mocked(api.get).mockRejectedValue(new ApiError('Bad Request', 400, {})); + vi.mocked(api.get).mockRejectedValue(new ApiError('Bad Request', { status: 400, body: {} })); const result = await callTool({ tools, @@ -100,12 +100,12 @@ describe('weather tools', () => { expect(tools.has('get_season_suggestions')).toBe(true); }); - it('calls GET /season-suggestions with destination param', async () => { + it('calls POST /season-suggestions with destination', async () => { const suggestions = { destination: 'Patagonia', seasons: [{ name: 'Summer', months: 'Dec-Feb', conditions: 'best' }], }; - vi.mocked(api.get).mockResolvedValue(suggestions); + vi.mocked(api.post).mockResolvedValue(suggestions); const result = await callTool({ tools, @@ -113,12 +113,12 @@ describe('weather tools', () => { args: { destination: 'Patagonia' }, }); - expect(api.get).toHaveBeenCalledWith('/season-suggestions', { 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.get).mockRejectedValue(new Error('Timeout')); + vi.mocked(api.post).mockRejectedValue(new Error('Timeout')); const result = await callTool({ tools, diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index f72a2a5855..0a3d3d4de8 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -1,139 +1,17 @@ /** - * PackRat API HTTP client used by MCP tool handlers. - * All requests are authenticated with the user-provided JWT. + * Re-export the PackRat API client primitives from the shared api-client package. + * MCP tool files import from here to keep their dependencies clean. */ -export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; - -export interface ApiResponse { - success: boolean; - data?: T; - error?: string; - message?: string; -} - -export class PackRatApiClient { - constructor( - private readonly baseUrl: string, - private readonly getAuthToken: () => string, - ) {} - - private get headers(): Record { - const token = this.getAuthToken(); - const base: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json', - }; - if (token) { - base.Authorization = `Bearer ${token}`; - } - return base; - } - - async get( - path: string, - params?: Record, - ): Promise { - const url = new URL(`${this.baseUrl}${path}`); - if (params) { - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - url.searchParams.set(key, String(value)); - } - } - } - const response = await fetch(url.toString(), { - method: 'GET', - headers: this.headers, - }); - return this.handleResponse(response); - } - - async post(path: string, body?: unknown): Promise { - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'POST', - headers: this.headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return this.handleResponse(response); - } - - async put(path: string, body?: unknown): Promise { - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'PUT', - headers: this.headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return this.handleResponse(response); - } - - async patch(path: string, body?: unknown): Promise { - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'PATCH', - headers: this.headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - return this.handleResponse(response); - } - - async delete(path: string): Promise { - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'DELETE', - headers: this.headers, - }); - return this.handleResponse(response); - } - - private async handleResponse(response: Response): Promise { - const text = await response.text(); - let body: unknown; - try { - body = JSON.parse(text); - } catch { - body = text; - } - - if (!response.ok) { - const errorMessage = - typeof body === 'object' && body !== null && 'error' in body - ? String((body as Record).error) - : `HTTP ${response.status}: ${response.statusText}`; - throw new ApiError(errorMessage, response.status, body); - } - - return body as T; - } -} - -export class ApiError extends Error { - // biome-ignore lint/complexity/useMaxParams: Error subclass needs message, status, and body - constructor( - message: string, - public readonly status: number, - public readonly body: unknown, - ) { - super(message); - this.name = 'ApiError'; - } -} - -/** Format a successful tool result */ -export function ok(data: unknown): { content: [{ type: 'text'; text: string }] } { - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - }; -} - -/** Format an error tool result */ -export function err(error: unknown): { content: [{ type: 'text'; text: string }]; isError: true } { - const message = - error instanceof ApiError - ? `API Error (${error.status}): ${error.message}` - : error instanceof Error - ? error.message - : String(error); - return { - content: [{ type: 'text', text: `Error: ${message}` }], - isError: true, - }; -} +export type { + ApiErrorOptions, + PackRatApiClient as PackRatClient, + QueryParams, +} from '@packrat/api-client'; +export { + ApiError, + createPackRatClient, + err, + ok, + PackRatApiClient, +} from '@packrat/api-client'; diff --git a/packages/mcp/src/constants.ts b/packages/mcp/src/constants.ts new file mode 100644 index 0000000000..e89779737e --- /dev/null +++ b/packages/mcp/src/constants.ts @@ -0,0 +1,13 @@ +/** Worker HTTP endpoint paths */ +export const WorkerRoute = { + Root: '/', + Health: '/health', + Mcp: '/mcp', +} as const; + +/** Service identification metadata */ +export const ServiceMeta = { + Name: 'packrat-mcp', + Version: '1.0.0', + Transport: 'streamable-http', +} as const; diff --git a/packages/mcp/src/enums.ts b/packages/mcp/src/enums.ts new file mode 100644 index 0000000000..7249ece11c --- /dev/null +++ b/packages/mcp/src/enums.ts @@ -0,0 +1,88 @@ +/** Pack entity category */ +export enum PackCategory { + Backpacking = 'backpacking', + Camping = 'camping', + Climbing = 'climbing', + Cycling = 'cycling', + Hiking = 'hiking', + Skiing = 'skiing', + Travel = 'travel', + General = 'general', +} + +/** Category for an individual item within a pack */ +export enum ItemCategory { + Shelter = 'shelter', + Sleep = 'sleep', + Clothing = 'clothing', + Footwear = 'footwear', + Navigation = 'navigation', + Safety = 'safety', + Food = 'food', + Water = 'water', + Hygiene = 'hygiene', + Tools = 'tools', +} + +/** Trail surface type for condition reports */ +export enum TrailSurface { + Paved = 'paved', + Gravel = 'gravel', + Dirt = 'dirt', + Rocky = 'rocky', + Snow = 'snow', + Mud = 'mud', +} + +/** Overall trail condition rating */ +export enum TrailCondition { + Excellent = 'excellent', + Good = 'good', + Fair = 'fair', + Poor = 'poor', +} + +/** Difficulty of water crossings */ +export enum CrossingDifficulty { + Easy = 'easy', + Moderate = 'moderate', + Difficult = 'difficult', +} + +/** Gear catalog sort field */ +export enum CatalogSortField { + Name = 'name', + Brand = 'brand', + Price = 'price', + Rating = 'ratingValue', + CreatedAt = 'createdAt', + UpdatedAt = 'updatedAt', + Usage = 'usage', +} + +/** Sort direction */ +export enum SortOrder { + Asc = 'asc', + Desc = 'desc', +} + +/** User outdoor experience level */ +export enum ExperienceLevel { + Beginner = 'beginner', + Intermediate = 'intermediate', + Advanced = 'advanced', +} + +/** Gear weight philosophy */ +export enum PackStyle { + Ultralight = 'ultralight', + Lightweight = 'lightweight', + Traditional = 'traditional', +} + +/** Weight vs durability priority for gear recommendations */ +export enum WeightPriority { + Ultralight = 'ultralight', + WeightConscious = 'weight-conscious', + DurabilityFirst = 'durability-first', +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 85697b5035..17c5c15331 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -19,7 +19,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpAgent } from 'agents/mcp'; -import { PackRatApiClient } from './client'; +import type { PackRatApiClient } from './client'; +import { createPackRatClient } from './client'; +import { ServiceMeta, WorkerRoute } from './constants'; import { registerPrompts } from './prompts'; import { registerResources } from './resources'; import { registerCatalogTools } from './tools/catalog'; @@ -56,14 +58,14 @@ export class PackRatMCP extends McpAgent> { initialState: State = { authToken: '' }; /** - * Public API client, accessible from tool registration functions. - * Lazily initialized on first use — reads auth token from current state. + * Typed API client, lazily initialised on first use. + * Reads the current auth token from Durable Object state on every request. */ private _api: PackRatApiClient | null = null; get api(): PackRatApiClient { if (!this._api) { - this._api = new PackRatApiClient(this.env.PACKRAT_API_URL, () => this.state.authToken); + this._api = createPackRatClient(this.env.PACKRAT_API_URL, () => this.state.authToken); } return this._api; } @@ -117,24 +119,23 @@ const BEARER_REGEX = /^Bearer\s+(\S+)/i; const mcpHandler = PackRatMCP.serve('/mcp'); export default { - // biome-ignore lint/complexity/useMaxParams: Cloudflare Worker requires (request, env, ctx) fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise { const url = new URL(request.url); // ── Health check ────────────────────────────────────────────────────── - if (url.pathname === '/' || url.pathname === '/health') { + if (url.pathname === WorkerRoute.Root || url.pathname === WorkerRoute.Health) { return Response.json({ status: 'ok', - service: 'packrat-mcp', - version: '1.0.0', - transport: 'streamable-http', - endpoint: '/mcp', + service: ServiceMeta.Name, + version: ServiceMeta.Version, + transport: ServiceMeta.Transport, + endpoint: WorkerRoute.Mcp, docs: 'https://packrat.world/docs/mcp', }); } // ── MCP endpoint ────────────────────────────────────────────────────── - if (url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) { + if (url.pathname === WorkerRoute.Mcp || url.pathname.startsWith(`${WorkerRoute.Mcp}/`)) { const authHeader = request.headers.get('Authorization'); const token = authHeader?.match(BEARER_REGEX)?.[1] ?? ''; @@ -164,8 +165,8 @@ export default { { error: 'Not Found', availableEndpoints: [ - { method: 'GET', path: '/', description: 'Health check' }, - { method: '*', path: '/mcp', description: 'MCP endpoint (Streamable HTTP)' }, + { method: 'GET', path: WorkerRoute.Root, description: 'Health check' }, + { method: '*', path: WorkerRoute.Mcp, description: 'MCP endpoint (Streamable HTTP)' }, ], }, { status: 404 }, diff --git a/packages/mcp/src/prompts.ts b/packages/mcp/src/prompts.ts index ff73b7fd0f..c5fbf4692b 100644 --- a/packages/mcp/src/prompts.ts +++ b/packages/mcp/src/prompts.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ExperienceLevel, PackCategory, PackStyle, WeightPriority } from './enums'; import type { AgentContext } from './types'; export function registerPrompts(agent: AgentContext): void { @@ -13,17 +14,17 @@ export function registerPrompts(agent: AgentContext): void { destination: z.string().describe('Trip destination (e.g. "John Muir Trail, CA")'), duration_days: z.string().describe('Number of days (e.g. "7")'), activity: z - .string() - .default('backpacking') - .describe('Primary activity: backpacking, camping, hiking, climbing, skiing'), + .nativeEnum(PackCategory) + .default(PackCategory.Backpacking) + .describe('Primary activity type'), season: z.string().optional().describe('Season or month (e.g. "July", "winter")'), experience_level: z - .enum(['beginner', 'intermediate', 'advanced']) - .default('intermediate') + .nativeEnum(ExperienceLevel) + .default(ExperienceLevel.Intermediate) .describe('User experience level'), pack_style: z - .enum(['ultralight', 'lightweight', 'traditional']) - .default('lightweight') + .nativeEnum(PackStyle) + .default(PackStyle.Lightweight) .describe('Preferred gear weight philosophy'), }, }, @@ -141,8 +142,8 @@ Prioritize the highest weight-savings-per-dollar swaps. Flag any items that are .describe('Specific gear category (e.g. "sleeping bag", "shell jacket")'), budget_usd: z.string().optional().describe('Maximum budget in USD'), weight_priority: z - .enum(['ultralight', 'weight-conscious', 'durability-first']) - .default('weight-conscious') + .nativeEnum(WeightPriority) + .default(WeightPriority.WeightConscious) .describe('Weight vs durability tradeoff preference'), }, }, diff --git a/packages/mcp/src/resources.ts b/packages/mcp/src/resources.ts index 36021e0c2d..cdcb093656 100644 --- a/packages/mcp/src/resources.ts +++ b/packages/mcp/src/resources.ts @@ -2,12 +2,12 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { ApiError } from './client'; import type { AgentContext } from './types'; -// biome-ignore lint/complexity/useMaxParams: uri, context, and error are all distinct and necessary -function resourceError(uri: string, context: string, e: unknown): object { - if (e instanceof ApiError) { - return { uri, error: e.message, status: e.status, context }; +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 }; } - return { uri, error: e instanceof Error ? e.message : String(e), context }; + return { uri, error: error instanceof Error ? error.message : String(error), context }; } export function registerResources(agent: AgentContext): void { @@ -36,7 +36,9 @@ export function registerResources(agent: AgentContext): void { { uri: uri.href, mimeType: 'application/json', - text: JSON.stringify(resourceError(uri.href, `pack:${String(packId)}`, e)), + text: JSON.stringify( + resourceError({ uri: uri.href, context: `pack:${String(packId)}`, error: e }), + ), }, ], }; @@ -68,7 +70,9 @@ export function registerResources(agent: AgentContext): void { { uri: uri.href, mimeType: 'application/json', - text: JSON.stringify(resourceError(uri.href, `trip:${String(tripId)}`, e)), + text: JSON.stringify( + resourceError({ uri: uri.href, context: `trip:${String(tripId)}`, error: e }), + ), }, ], }; @@ -100,7 +104,9 @@ export function registerResources(agent: AgentContext): void { { uri: uri.href, mimeType: 'application/json', - text: JSON.stringify(resourceError(uri.href, `catalog:${String(itemId)}`, e)), + text: JSON.stringify( + resourceError({ uri: uri.href, context: `catalog:${String(itemId)}`, error: e }), + ), }, ], }; @@ -136,7 +142,9 @@ export function registerResources(agent: AgentContext): void { { uri: uri.href, mimeType: 'application/json', - text: JSON.stringify(resourceError(uri.href, 'gear_categories', e)), + text: JSON.stringify( + resourceError({ uri: uri.href, context: 'gear_categories', error: e }), + ), }, ], }; diff --git a/packages/mcp/src/tools/catalog.ts b/packages/mcp/src/tools/catalog.ts index 96261658b4..003daefe1e 100644 --- a/packages/mcp/src/tools/catalog.ts +++ b/packages/mcp/src/tools/catalog.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { err, ok } from '../client'; +import { CatalogSortField, SortOrder } from '../enums'; import type { AgentContext } from '../types'; export function registerCatalogTools(agent: AgentContext): void { @@ -28,21 +29,18 @@ export function registerCatalogTools(agent: AgentContext): void { .max(50) .default(10) .describe('Number of results to return (default 10)'), - offset: z.number().int().min(0).default(0).describe('Pagination offset'), - sort_by: z - .enum(['name', 'brand', 'price', 'ratingValue', 'createdAt', 'updatedAt', 'usage']) - .optional() - .describe('Sort field'), - sort_order: z.enum(['asc', 'desc']).default('asc').describe('Sort direction'), + 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'), }, }, - async ({ query, category, limit, offset, sort_by, sort_order }) => { + async ({ query, category, limit, page, sort_by, sort_order }) => { try { const data = await agent.api.get('/catalog', { q: query, category, limit, - offset, + page, 'sort[field]': sort_by, 'sort[order]': sort_order, }); @@ -74,12 +72,11 @@ export function registerCatalogTools(agent: AgentContext): void { .max(30) .default(8) .describe('Number of results to return (default 8)'), - offset: z.number().int().min(0).default(0).describe('Pagination offset'), }, }, - async ({ query, limit, offset }) => { + async ({ query, limit }) => { try { - const data = await agent.api.get('/catalog/vector-search', { q: query, limit, offset }); + const data = await agent.api.get('/catalog/vector-search', { q: query, limit }); return ok(data); } catch (e) { return err(e); @@ -147,21 +144,20 @@ export function registerCatalogTools(agent: AgentContext): void { }, async ({ item_ids }) => { try { - const items = await Promise.all(item_ids.map((id) => agent.api.get(`/catalog/${id}`))); - const comparison = items.map((item: unknown) => { - const it = item 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, - }; - }); + const items = await Promise.all( + item_ids.map((id) => agent.api.get>(`/catalog/${id}`)), + ); + const comparison = items.map((it) => ({ + 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) || 999999) - (Number(b.weightGrams) || 999999), diff --git a/packages/mcp/src/tools/packs.ts b/packages/mcp/src/tools/packs.ts index bb9abe9beb..208c0d5dbf 100644 --- a/packages/mcp/src/tools/packs.ts +++ b/packages/mcp/src/tools/packs.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { err, ok } from '../client'; +import { ItemCategory, PackCategory } from '../enums'; import type { AgentContext } from '../types'; export function registerPackTools(agent: AgentContext): void { @@ -11,25 +12,15 @@ export function registerPackTools(agent: AgentContext): void { description: 'List all packs belonging to the authenticated user. Returns pack summaries including name, category, item count, and total weight.', inputSchema: { - limit: z - .number() - .int() - .min(1) - .max(100) - .default(20) - .describe('Maximum number of packs to return (default 20)'), - offset: z.number().int().min(0).default(0).describe('Pagination offset (default 0)'), - category: z - .string() - .optional() - .describe( - 'Filter by pack category (e.g. "backpacking", "camping", "climbing", "cycling")', - ), + include_public: z + .boolean() + .default(false) + .describe('Include public packs from other users'), }, }, - async ({ limit, offset, category }) => { + async ({ include_public }) => { try { - const data = await agent.api.get('/packs', { limit, offset, category }); + const data = await agent.api.get('/packs', { includePublic: include_public ? 1 : 0 }); return ok(data); } catch (e) { return err(e); @@ -68,11 +59,7 @@ export function registerPackTools(agent: AgentContext): void { inputSchema: { name: z.string().min(1).describe('Pack name (e.g. "3-Day Yosemite Trip")'), description: z.string().optional().describe('Optional longer description of the pack'), - category: z - .string() - .describe( - 'Pack category — one of: backpacking, camping, climbing, cycling, hiking, skiing, travel, general', - ), + category: z.nativeEnum(PackCategory).describe('Pack category'), is_public: z .boolean() .default(false) @@ -111,7 +98,7 @@ export function registerPackTools(agent: AgentContext): void { pack_id: z.string().describe('The unique pack ID to update'), name: z.string().min(1).optional().describe('New pack name'), description: z.string().optional().nullable().describe('New description'), - category: z.string().optional().describe('New category'), + category: z.nativeEnum(PackCategory).optional().describe('New category'), is_public: z.boolean().optional().describe('Update public visibility'), tags: z.array(z.string()).optional().describe('New tags (replaces existing tags)'), }, @@ -124,7 +111,7 @@ export function registerPackTools(agent: AgentContext): void { 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.patch(`/packs/${pack_id}`, body); + const data = await agent.api.put(`/packs/${pack_id}`, body); return ok(data); } catch (e) { return err(e); @@ -162,11 +149,7 @@ export function registerPackTools(agent: AgentContext): void { inputSchema: { pack_id: z.string().describe('The pack ID to add the item to'), name: z.string().min(1).describe('Item name'), - category: z - .string() - .describe( - 'Item category (e.g. "shelter", "sleep", "clothing", "footwear", "navigation", "safety", "food", "water", "hygiene", "tools")', - ), + category: z.nativeEnum(ItemCategory).describe('Item category'), weight_grams: z.number().min(0).describe('Item weight in grams'), quantity: z.number().int().min(1).default(1).describe('Number of this item'), catalog_item_id: z @@ -223,13 +206,12 @@ export function registerPackTools(agent: AgentContext): void { { description: 'Remove an item from a pack (soft-delete).', inputSchema: { - pack_id: z.string().describe('The pack ID'), item_id: z.string().describe('The item ID to remove'), }, }, - async ({ pack_id, item_id }) => { + async ({ item_id }) => { try { - const data = await agent.api.delete(`/packs/${pack_id}/items/${item_id}`); + const data = await agent.api.delete(`/packs/items/${item_id}`); return ok(data); } catch (e) { return err(e); @@ -312,11 +294,7 @@ export function registerPackTools(agent: AgentContext): void { "Identify missing essential gear categories for a specific activity type. 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 - .string() - .describe( - 'Activity type: backpacking, camping, climbing, hiking, skiing, cycling, or travel', - ), + activity: z.nativeEnum(PackCategory).describe('Activity type to check gear gaps for'), duration_days: z .number() .int() diff --git a/packages/mcp/src/tools/trail-conditions.ts b/packages/mcp/src/tools/trail-conditions.ts index ffa21e746b..d25fdb516f 100644 --- a/packages/mcp/src/tools/trail-conditions.ts +++ b/packages/mcp/src/tools/trail-conditions.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { err, ok } from '../client'; +import { CrossingDifficulty, TrailCondition, TrailSurface } from '../enums'; import type { AgentContext } from '../types'; export function registerTrailConditionTools(agent: AgentContext): void { @@ -47,12 +48,8 @@ export function registerTrailConditionTools(agent: AgentContext): void { .string() .optional() .describe('Region or state (e.g. "California", "Maine")'), - surface: z - .enum(['paved', 'gravel', 'dirt', 'rocky', 'snow', 'mud']) - .describe('Current trail surface type'), - overall_condition: z - .enum(['excellent', 'good', 'fair', 'poor']) - .describe('Overall trail condition'), + surface: z.nativeEnum(TrailSurface).describe('Current trail surface type'), + overall_condition: z.nativeEnum(TrailCondition).describe('Overall trail condition'), hazards: z .array(z.string()) .optional() @@ -67,7 +64,7 @@ export function registerTrailConditionTools(agent: AgentContext): void { .optional() .describe('Number of water crossings on the trail (0–20)'), water_crossing_difficulty: z - .enum(['easy', 'moderate', 'difficult']) + .nativeEnum(CrossingDifficulty) .optional() .describe('Difficulty of water crossings if present'), notes: z diff --git a/packages/mcp/src/tools/trips.ts b/packages/mcp/src/tools/trips.ts index 752d134e58..fef3488661 100644 --- a/packages/mcp/src/tools/trips.ts +++ b/packages/mcp/src/tools/trips.ts @@ -11,19 +11,15 @@ 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: { - limit: z - .number() - .int() - .min(1) - .max(100) - .default(20) - .describe('Maximum number of trips to return'), - offset: z.number().int().min(0).default(0).describe('Pagination offset'), + include_public: z + .boolean() + .default(false) + .describe('Include public trips from other users'), }, }, - async ({ limit, offset }) => { + async ({ include_public }) => { try { - const data = await agent.api.get('/trips', { limit, offset }); + const data = await agent.api.get('/trips', { includePublic: include_public ? 1 : 0 }); return ok(data); } catch (e) { return err(e); diff --git a/packages/mcp/src/tools/weather.ts b/packages/mcp/src/tools/weather.ts index 3ef23955b8..e77c5f1737 100644 --- a/packages/mcp/src/tools/weather.ts +++ b/packages/mcp/src/tools/weather.ts @@ -26,20 +26,21 @@ export function registerWeatherTools(agent: AgentContext): void { async ({ location }) => { try { // Step 1: search for the location to get its ID - const searchResults = await agent.api.get<{ id?: string; results?: Array<{ id: string }> }>( - '/weather/search', - { q: location }, - ); + const searchResults = await agent.api.get>('/weather/search', { + q: location, + }); + const locationId = - (searchResults as Record).id ?? - ((searchResults as Record).results as Array<{ id: string }>)?.[0]?.id; + 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) }); + const forecast = await agent.api.get('/weather/forecast', { + id: String(locationId), + }); return ok(forecast); } catch (e) { return err(e); @@ -86,7 +87,7 @@ export function registerWeatherTools(agent: AgentContext): void { }, async ({ destination }) => { try { - const data = await agent.api.get('/season-suggestions', { destination }); + const data = await agent.api.post('/season-suggestions', { destination }); return ok(data); } catch (e) { return err(e); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 3761214881..ad41882bed 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -9,8 +9,12 @@ "skipLibCheck": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, - "outDir": "dist", - "rootDir": "src" + "baseUrl": ".", + "paths": { + "@packrat/api-client": ["../api-client/src/index.ts"], + "@packrat/api-client/*": ["../api-client/src/*"], + "@packrat/api/*": ["../api/src/*"] + } }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index a5126775f5..c77be4aa6e 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -7,6 +7,11 @@ import { defineConfig } from 'vitest/config'; * Runs in standard Node.js environment with Cloudflare/Workers APIs mocked. */ export default defineConfig({ + resolve: { + alias: { + '@packrat/api-client': resolve(__dirname, '../api-client/src/index.ts'), + }, + }, test: { name: 'mcp-unit', environment: 'node', diff --git a/packages/ui/package.json b/packages/ui/package.json index 9ddf8fd3eb..e021478a15 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/ui", - "version": "2.0.20", + "version": "2.0.21", "private": true, "dependencies": { "@packrat-ai/nativewindui": "^2.0.2" diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 86a8ebc606..9884e1781f 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/web-ui", - "version": "2.0.20", + "version": "2.0.21", "private": true, "type": "module", "exports": { @@ -55,7 +55,6 @@ "react-day-picker": "9.14.0", "react-hook-form": "^7.58.1", "react-resizable-panels": "^4.10.0", - "recharts": "3.8.1", "sonner": "^2.0.7", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", @@ -64,11 +63,13 @@ "devDependencies": { "@types/react": "~19.1.10", "react": "catalog:", + "recharts": "3.8.1", "tailwindcss": "catalog:", "typescript": "catalog:" }, "peerDependencies": { "react": "catalog:", + "recharts": "3.8.1", "tailwindcss": "catalog:" } } diff --git a/packages/web-ui/src/components/chart.tsx b/packages/web-ui/src/components/chart.tsx index ba87a9c4fc..fa378bf108 100644 --- a/packages/web-ui/src/components/chart.tsx +++ b/packages/web-ui/src/components/chart.tsx @@ -64,7 +64,7 @@ const ChartContainer = React.forwardRef< ChartContainer.displayName = 'Chart'; const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); + const colorConfig = Object.entries(config).filter(([, conf]) => conf.theme ?? conf.color); if (!colorConfig.length) { return null; @@ -72,6 +72,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { return (