diff --git a/.github/workflows/client-e2e.yml b/.github/workflows/client-e2e.yml index ea9d370..97b2486 100644 --- a/.github/workflows/client-e2e.yml +++ b/.github/workflows/client-e2e.yml @@ -44,7 +44,7 @@ jobs: - name: 🟩 Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: "22" + node-version-file: ".nvmrc" - name: 📥 Install dependencies run: npm ci --no-audit --no-fund diff --git a/.github/workflows/client-generate.yml b/.github/workflows/client-generate.yml index 755a744..e59cb6d 100644 --- a/.github/workflows/client-generate.yml +++ b/.github/workflows/client-generate.yml @@ -35,7 +35,7 @@ jobs: - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: "22" + node-version-file: ".nvmrc" - name: Install dependencies run: npm ci --no-audit --no-fund diff --git a/.github/workflows/client-lint-test.yml b/.github/workflows/client-lint-test.yml index cfef531..7c63340 100644 --- a/.github/workflows/client-lint-test.yml +++ b/.github/workflows/client-lint-test.yml @@ -39,7 +39,7 @@ jobs: - name: 🟩 Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: "22" + node-version-file: ".nvmrc" - name: 📥 Install dependencies run: npm ci --no-audit --no-fund diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..4b9a516 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +npm run build +npm run test +npm run lint diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..32a2d7b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.22.1 diff --git a/.prettierignore b/.prettierignore index 1aa8372..47d7db3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,8 @@ # Build outputs dist build -../mcpgateway/static/app +server/dist +server/public # Dependencies node_modules @@ -15,6 +16,9 @@ package-lock.json pnpm-lock.yaml yarn.lock +# Committed API spec, not hand-formatted +openapi.json + # Coverage coverage .nyc_output diff --git a/README.md b/README.md index 3175a7f..4c102c2 100644 --- a/README.md +++ b/README.md @@ -339,25 +339,25 @@ client/ ## Available Scripts -| Script | Description | -| ----------------------- | -------------------------------- | -| `npm run dev` | Start development server | -| `npm run build` | Build for production | +| Script | Description | +| ----------------------- | ---------------------------------------- | +| `npm run dev` | Start development server | +| `npm run build` | Build for production | | `npm run generate` | Regenerate API types from `openapi.json` | -| `npm run preview` | Preview production build | -| `npm run lint` | Check for linting errors | -| `npm run lint:fix` | Auto-fix linting errors | -| `npm run format` | Format all files with Prettier | -| `npm run format:check` | Check formatting without changes | -| `npm run test` | Run tests in watch mode | -| `npm run test:run` | Run tests once (CI mode) | -| `npm run test:ui` | Run tests with UI | -| `npm run test:coverage` | Generate coverage report | -| `npm run e2e` | Run Playwright E2E tests | -| `npm run e2e:ui` | Playwright UI mode | -| `npm run e2e:debug` | Playwright Inspector | -| `npm run e2e:install` | Install Playwright browsers | -| `npm run e2e:report` | Open last Playwright report | +| `npm run preview` | Preview production build | +| `npm run lint` | Check for linting errors | +| `npm run lint:fix` | Auto-fix linting errors | +| `npm run format` | Format all files with Prettier | +| `npm run format:check` | Check formatting without changes | +| `npm run test` | Run tests in watch mode | +| `npm run test:run` | Run tests once (CI mode) | +| `npm run test:ui` | Run tests with UI | +| `npm run test:coverage` | Generate coverage report | +| `npm run e2e` | Run Playwright E2E tests | +| `npm run e2e:ui` | Playwright UI mode | +| `npm run e2e:debug` | Playwright Inspector | +| `npm run e2e:install` | Install Playwright browsers | +| `npm run e2e:report` | Open last Playwright report | ## Internationalization (i18n) diff --git a/eslint.config.js b/eslint.config.js index 7df8cde..569d286 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,20 +9,19 @@ import prettierConfig from "eslint-config-prettier"; import globals from "globals"; // Load .prettierrc explicitly instead of letting eslint-plugin-prettier -// resolve it on its own — this repo has a second, differently-configured -// prettier.config.js one directory up (repo root), and relying on the -// plugin's own cosmiconfig search risks it picking that one up instead, -// producing formatting eslint --fix disagrees with the prettier CLI on. +// resolve it via its own cosmiconfig search, so eslint --fix always agrees +// with the prettier CLI. const __dirname = path.dirname(fileURLToPath(import.meta.url)); const prettierOptions = JSON.parse(fs.readFileSync(path.join(__dirname, ".prettierrc"), "utf8")); export default tseslint.config( { ignores: [ - "../mcpgateway/static/app", "src/generated", "dist", "build", + "server/dist", + "server/public", "playwright-report", "test-results", ], @@ -90,4 +89,33 @@ export default tseslint.config( "no-new-func": "error", }, }, + { + extends: [...tseslint.configs.recommended], + files: ["server/**/*.ts"], + languageOptions: { + globals: globals.node, + }, + plugins: { + prettier: prettierPlugin, + }, + rules: { + ...prettierConfig.rules, + "prettier/prettier": ["error", prettierOptions], + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error", + + // Allow `_`-prefixed locals to signal intentional discard (destructured + // rest patterns that strip a key, unused catch bindings, etc). + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + }, + }, ); diff --git a/orval.config.ts b/orval.config.ts index 3051414..1a319e3 100644 --- a/orval.config.ts +++ b/orval.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ }, }, hooks: { - "afterAllFilesWrite": "prettier src/generated --write" - } + afterAllFilesWrite: "prettier src/generated --write", + }, }, }); diff --git a/package-lock.json b/package-lock.json index 94d080e..f4f0f17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,9 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.0.0", + "husky": "^9.1.7", "jsdom": "^25.0.1", + "lint-staged": "^17.3.0", "msw": "^2.14.2", "orval": "^8.18.0", "prettier": "^3.4.2", @@ -8537,6 +8539,22 @@ "node": ">=18.18.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -9788,6 +9806,30 @@ "uc.micro": "^2.0.0" } }, + "node_modules/lint-staged": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.3.0.tgz", + "integrity": "sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.5", + "string-argv": "^0.3.2", + "tinyexec": "^1.2.4" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10942,9 +10984,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index e9e5054..73c8b84 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,19 @@ "private": true, "version": "0.1.0", "type": "module", + "engines": { + "node": ">=22.22.1" + }, "scripts": { "dev": "vite", "dev:e2e": "vite --base=/ --port 5173 --strictPort", "build": "npm run generate && tsc -b && vite build", "build:watch": "vite build --watch", "preview": "vite preview", - "lint": "eslint src e2e", - "lint:fix": "eslint src e2e --fix", - "format": "prettier --write \"{src,e2e}/**/*.{ts,tsx,css,json}\"", - "format:check": "prettier --check \"{src,e2e}/**/*.{ts,tsx,css,json}\"", + "lint": "eslint --no-error-on-unmatched-pattern src server e2e", + "lint:fix": "eslint --no-error-on-unmatched-pattern src server e2e --fix", + "format": "prettier --write --ignore-unknown \"*.{ts,tsx,js,mjs,json,css,md}\" \"{src,server,e2e}/**/*.{ts,tsx,css,json}\"", + "format:check": "prettier --check --ignore-unknown \"*.{ts,tsx,js,mjs,json,css,md}\" \"{src,server,e2e}/**/*.{ts,tsx,css,json}\"", "test": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest", @@ -25,7 +28,8 @@ "e2e:report": "playwright show-report", "i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --out-file src/i18n/extracted.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'", "i18n:compile": "formatjs compile-folder --ast src/i18n/locales src/i18n/compiled", - "generate": "orval" + "generate": "orval", + "prepare": "husky || true" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", @@ -65,7 +69,9 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "globals": "^16.0.0", + "husky": "^9.1.7", "jsdom": "^25.0.1", + "lint-staged": "^17.3.0", "msw": "^2.14.2", "orval": "^8.18.0", "prettier": "^3.4.2", @@ -74,5 +80,9 @@ "typescript-eslint": "^8.32.0", "vite": "^6.4.3", "vitest": "^4.1.9" + }, + "lint-staged": { + "*.{ts,tsx,js,mjs,json,css,md}": "prettier --write --ignore-unknown", + "{src,server,e2e}/**/*.{ts,tsx}": "eslint --fix" } } diff --git a/server/test/proxy.test.ts b/server/test/proxy.test.ts index 3050258..34d25d2 100644 --- a/server/test/proxy.test.ts +++ b/server/test/proxy.test.ts @@ -15,8 +15,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; let upstream: Server; let upstreamOrigin: string; let lastRequest: - | { path: string; authorization: string | undefined; method: string; body: string } - | undefined; + { path: string; authorization: string | undefined; method: string; body: string } | undefined; beforeAll(async () => { upstream = createServer((req: IncomingMessage, res) => { diff --git a/src/auth/AuthContext.test.tsx b/src/auth/AuthContext.test.tsx index 6767405..1c7d43c 100644 --- a/src/auth/AuthContext.test.tsx +++ b/src/auth/AuthContext.test.tsx @@ -104,7 +104,7 @@ describe("AuthContext", () => { vi.mocked(api.get).mockResolvedValueOnce({ authenticated: true, user: mockUser, - csrfToken: "test-csrf-token", + csrfToken: "session-csrf-token", }); render( @@ -122,7 +122,7 @@ describe("AuthContext", () => { expect(screen.getByTestId("auth-status")).toHaveTextContent("authenticated"); expect(screen.getByTestId("user-email")).toHaveTextContent("user@example.com"); expect(api.get).toHaveBeenCalledWith("/auth/session"); - expect(setCsrfToken).toHaveBeenCalledWith("test-csrf-token"); + expect(setCsrfToken).toHaveBeenCalledWith("session-csrf-token"); }); it("treats an unauthenticated session response as a guest", async () => { @@ -202,7 +202,7 @@ describe("AuthContext", () => { vi.mocked(api.post).mockResolvedValueOnce({ user: mockUser, - csrfToken: "test-csrf-token", + csrfToken: "session-csrf-token", }); screen.getByText("Login").click(); @@ -217,7 +217,7 @@ describe("AuthContext", () => { { email: "test@example.com", password: "pass" }, { authenticated: false }, ); - expect(setCsrfToken).toHaveBeenCalledWith("test-csrf-token"); + expect(setCsrfToken).toHaveBeenCalledWith("session-csrf-token"); }); it("handles successful logout", async () => { @@ -234,7 +234,7 @@ describe("AuthContext", () => { vi.mocked(api.get).mockResolvedValueOnce({ authenticated: true, user: mockUser, - csrfToken: "test-csrf-token", + csrfToken: "session-csrf-token", }); render( @@ -274,7 +274,7 @@ describe("AuthContext", () => { vi.mocked(api.get).mockResolvedValueOnce({ authenticated: true, user: mockUser, - csrfToken: "test-csrf-token", + csrfToken: "session-csrf-token", }); render( @@ -307,11 +307,10 @@ describe("AuthContext", () => { email_verified: true, password_change_required: false, }; - vi.mocked(api.get).mockResolvedValueOnce({ authenticated: true, user: mockUser, - csrfToken: "test-csrf-token", + csrfToken: "session-csrf-token", }); // Hold the permissions fetch open so we can observe the loading window. @@ -349,7 +348,7 @@ describe("AuthContext", () => { email_verified: true, password_change_required: false, }, - csrfToken: "test-csrf-token", + csrfToken: "session-csrf-token", }); render( diff --git a/tsconfig.json b/tsconfig.json index 7e850ee..d3bc091 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,6 @@ { "files": [], - "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.app.json" } - ], + "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.app.json" }], "compilerOptions": { "baseUrl": ".", "paths": { diff --git a/vite.bff.config.ts b/vite.bff.config.ts new file mode 100644 index 0000000..d53ac61 --- /dev/null +++ b/vite.bff.config.ts @@ -0,0 +1,18 @@ +import { mergeConfig, defineConfig } from "vite"; + +import baseConfig from "./vite.config"; + +// Alternate build target for a BFF-served SPA: outputs to server/public/ +// with base '/', instead of vite.config.ts's default (dist/ with base +// '/static/app/', for FastAPI's static mount). Everything else — plugins, +// chunking, etc. — is inherited from the base config. +export default mergeConfig( + baseConfig, + defineConfig({ + base: "/", + build: { + outDir: "server/public", + emptyOutDir: true, + }, + }), +); diff --git a/vite.config.ts b/vite.config.ts index 3834e66..af4ebd9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,9 +22,7 @@ export default defineConfig({ base: "/", build: { - // BFF (server/) serves this directory as static files — see - // server/src/plugins/static.ts. - outDir: "server/public", + outDir: "dist", emptyOutDir: true, manifest: true, sourcemap: false, @@ -45,7 +43,8 @@ export default defineConfig({ id.includes("react-intl") || id.includes("@formatjs") || id.includes("/sonner/") - ) return "vendor-react"; + ) + return "vendor-react"; if (id.includes("@radix-ui") || id.includes("radix-ui")) return "vendor-radix"; if (id.includes("lucide-react")) return "vendor-lucide"; return "vendor";