techbro 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +453 -2
  2. package/assets/baseline-rules.yml +60 -0
  3. package/assets/caller-workflow.yml +26 -0
  4. package/assets/review-action.yml +27 -0
  5. package/assets/review-config.json +4 -0
  6. package/assets/review-workflow.yml +443 -0
  7. package/assets/review.mjs +721 -0
  8. package/bin/techbro.js +9 -0
  9. package/package.json +23 -4
  10. package/src/cli.js +690 -0
  11. package/src/cluster.js +134 -0
  12. package/src/config.js +35 -0
  13. package/src/doctor.js +125 -0
  14. package/src/github.js +152 -0
  15. package/src/init.js +167 -0
  16. package/src/kube-project.js +468 -0
  17. package/src/kube.js +317 -0
  18. package/src/managed.js +63 -0
  19. package/src/project.js +202 -0
  20. package/src/review.js +165 -0
  21. package/src/secret.js +80 -0
  22. package/src/setup.js +91 -0
  23. package/src/team-workflow.js +106 -0
  24. package/src/team.js +145 -0
  25. package/src/templates.js +44 -0
  26. package/src/upgrade.js +94 -0
  27. package/templates/_default/.agents/skills/README.md +36 -0
  28. package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
  29. package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
  30. package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
  31. package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
  32. package/templates/_default/.editorconfig +15 -0
  33. package/templates/_default/.env.example +9 -0
  34. package/templates/_default/.github/workflows/ci.yml +60 -0
  35. package/templates/_default/.nvmrc +1 -0
  36. package/templates/_default/Cargo.lock +1834 -0
  37. package/templates/_default/Cargo.toml +3 -0
  38. package/templates/_default/README.md +257 -0
  39. package/templates/_default/TEMPLATE.md +29 -0
  40. package/templates/_default/_dockerignore +8 -0
  41. package/templates/_default/_gitignore +8 -0
  42. package/templates/_default/apps/backend/Cargo.toml +22 -0
  43. package/templates/_default/apps/backend/Dockerfile +34 -0
  44. package/templates/_default/apps/backend/README.md +13 -0
  45. package/templates/_default/apps/backend/src/config.rs +111 -0
  46. package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
  47. package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
  48. package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
  49. package/templates/_default/apps/backend/src/error.rs +115 -0
  50. package/templates/_default/apps/backend/src/lib.rs +7 -0
  51. package/templates/_default/apps/backend/src/main.rs +71 -0
  52. package/templates/_default/apps/backend/src/models/item.rs +94 -0
  53. package/templates/_default/apps/backend/src/models/mod.rs +1 -0
  54. package/templates/_default/apps/backend/src/routes.rs +94 -0
  55. package/templates/_default/apps/backend/src/state.rs +12 -0
  56. package/templates/_default/apps/backend/src/views/items.rs +18 -0
  57. package/templates/_default/apps/backend/src/views/mod.rs +1 -0
  58. package/templates/_default/apps/backend/tests/api.rs +234 -0
  59. package/templates/_default/apps/frontend/.env.example +2 -0
  60. package/templates/_default/apps/frontend/.prettierignore +3 -0
  61. package/templates/_default/apps/frontend/.prettierrc +1 -0
  62. package/templates/_default/apps/frontend/Dockerfile +15 -0
  63. package/templates/_default/apps/frontend/README.md +39 -0
  64. package/templates/_default/apps/frontend/_dockerignore +3 -0
  65. package/templates/_default/apps/frontend/_gitignore +24 -0
  66. package/templates/_default/apps/frontend/components.json +25 -0
  67. package/templates/_default/apps/frontend/eslint.config.js +37 -0
  68. package/templates/_default/apps/frontend/index.html +13 -0
  69. package/templates/_default/apps/frontend/jsconfig.json +7 -0
  70. package/templates/_default/apps/frontend/nginx.conf +30 -0
  71. package/templates/_default/apps/frontend/package-lock.json +4338 -0
  72. package/templates/_default/apps/frontend/package.json +49 -0
  73. package/templates/_default/apps/frontend/public/favicon.svg +1 -0
  74. package/templates/_default/apps/frontend/src/App.jsx +11 -0
  75. package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
  76. package/templates/_default/apps/frontend/src/api/client.js +50 -0
  77. package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
  78. package/templates/_default/apps/frontend/src/api/items.js +26 -0
  79. package/templates/_default/apps/frontend/src/api/system.js +11 -0
  80. package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
  81. package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
  82. package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
  83. package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
  84. package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
  85. package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
  86. package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
  87. package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
  88. package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
  89. package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
  90. package/templates/_default/apps/frontend/src/index.css +130 -0
  91. package/templates/_default/apps/frontend/src/lib/config.js +2 -0
  92. package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
  93. package/templates/_default/apps/frontend/src/main.jsx +11 -0
  94. package/templates/_default/apps/frontend/src/models/items.js +24 -0
  95. package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
  96. package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
  97. package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
  98. package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
  99. package/templates/_default/apps/frontend/src/routes.jsx +17 -0
  100. package/templates/_default/apps/frontend/src/shadcn.css +655 -0
  101. package/templates/_default/apps/frontend/src/test/setup.js +1 -0
  102. package/templates/_default/apps/frontend/vite.config.js +25 -0
  103. package/templates/_default/database/Dockerfile +9 -0
  104. package/templates/_default/database/README.md +15 -0
  105. package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
  106. package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
  107. package/templates/_default/docker-compose.prod.yml +53 -0
  108. package/templates/_default/docker-compose.yml +69 -0
  109. package/templates/_default/docs/api/README.md +41 -0
  110. package/templates/_default/docs/api/openapi.yaml +152 -0
  111. package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
  112. package/templates/_default/docs/decisions/README.md +6 -0
  113. package/templates/_default/docs/deployment.md +46 -0
  114. package/templates/_default/docs/docker.md +63 -0
  115. package/templates/_default/docs/getting-started.md +214 -0
  116. package/templates/_default/package.json +15 -0
  117. package/templates/_default/rust-toolchain.toml +3 -0
  118. package/templates/_default/scripts/check +10 -0
  119. package/templates/_default/scripts/db-migrate +25 -0
  120. package/templates/_default/scripts/install-hooks +5 -0
  121. package/templates/_default/scripts/pre-push +3 -0
  122. package/templates/_default/scripts/setup +26 -0
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview",
11
+ "format": "prettier --write .",
12
+ "format:check": "prettier --check .",
13
+ "test": "vitest",
14
+ "test:run": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@base-ui/react": "^1.8.0",
18
+ "@fontsource-variable/geist": "^5.3.0",
19
+ "class-variance-authority": "^0.7.1",
20
+ "clsx": "^2.1.1",
21
+ "lucide-react": "^1.53.0",
22
+ "react": "^19.2.8",
23
+ "react-dom": "^19.2.8",
24
+ "react-router": "^7.18.4",
25
+ "tailwind-merge": "^3.7.0",
26
+ "tw-animate-css": "^1.4.0"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/js": "^10.0.1",
30
+ "@tailwindcss/vite": "^4.3.3",
31
+ "@testing-library/jest-dom": "^7.0.1",
32
+ "@testing-library/react": "^16.3.3",
33
+ "@testing-library/user-event": "^14.6.7",
34
+ "@vitejs/plugin-react": "^6.1.1",
35
+ "eslint": "^10.12.0",
36
+ "eslint-config-prettier": "^10.1.8",
37
+ "eslint-plugin-react-hooks": "^7.1.1",
38
+ "eslint-plugin-react-refresh": "^0.5.7",
39
+ "globals": "^17.13.0",
40
+ "jsdom": "^29.1.1",
41
+ "prettier": "^3.9.9",
42
+ "tailwindcss": "^4.3.3",
43
+ "vite": "^8.3.0",
44
+ "vitest": "^5.0.3"
45
+ },
46
+ "engines": {
47
+ "node": ">=22"
48
+ }
49
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
@@ -0,0 +1,11 @@
1
+ import { Outlet } from "react-router";
2
+ import Layout from "@/components/Layout";
3
+ import { appName } from "@/lib/config";
4
+
5
+ export default function App() {
6
+ return (
7
+ <Layout appName={appName}>
8
+ <Outlet />
9
+ </Layout>
10
+ );
11
+ }
@@ -0,0 +1,40 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { createMemoryRouter, RouterProvider } from "react-router";
4
+ import { routes } from "./routes";
5
+
6
+ function renderAt(path) {
7
+ render(<RouterProvider router={createMemoryRouter(routes, { initialEntries: [path] })} />);
8
+ }
9
+
10
+ beforeEach(() => {
11
+ vi.stubGlobal(
12
+ "fetch",
13
+ vi.fn((url) =>
14
+ Promise.resolve(
15
+ new Response(JSON.stringify(url.startsWith("/api") ? { items: [] } : { status: "ok" })),
16
+ ),
17
+ ),
18
+ );
19
+ });
20
+ afterEach(() => vi.unstubAllGlobals());
21
+
22
+ describe("routing", () => {
23
+ it("starts on the welcome page", async () => {
24
+ renderAt("/");
25
+ expect(screen.getByRole("heading", { name: "Project Template" })).toBeInTheDocument();
26
+ expect(await screen.findAllByText("OK")).toHaveLength(2);
27
+ });
28
+
29
+ it("navigates to items via the nav link", async () => {
30
+ renderAt("/");
31
+ await userEvent.click(screen.getByRole("link", { name: "Items" }));
32
+ expect(await screen.findByLabelText("Item name")).toBeInTheDocument();
33
+ expect(screen.getByRole("link", { name: "Items" })).toHaveAttribute("aria-current", "page");
34
+ });
35
+
36
+ it("renders items directly at /items", async () => {
37
+ renderAt("/items");
38
+ expect(await screen.findByLabelText("Item name")).toBeInTheDocument();
39
+ });
40
+ });
@@ -0,0 +1,50 @@
1
+ /** Error thrown for non-2xx responses; carries the backend error shape. */
2
+ export class ApiError extends Error {
3
+ /**
4
+ * @param {number} status HTTP status code
5
+ * @param {{error?: {code?: string, message?: string, details?: unknown[]}, requestId?: string} | null} body
6
+ * @param {string | null} [traceId] Correlation ID from the response header
7
+ */
8
+ constructor(status, body, traceId = null) {
9
+ super(body?.error?.message ?? `Request failed with status ${status}`);
10
+ this.name = "ApiError";
11
+ this.status = status;
12
+ this.code = body?.error?.code ?? "UNKNOWN_ERROR";
13
+ this.details = body?.error?.details ?? [];
14
+ this.requestId = body?.requestId ?? null;
15
+ this.traceId = traceId;
16
+ }
17
+ }
18
+
19
+ const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
20
+
21
+ /**
22
+ * Fetch JSON from the backend, generating a per-request trace UUID unless supplied.
23
+ * @param {string} path Path beginning with "/"
24
+ * @param {RequestInit & {json?: unknown}} [options]
25
+ * @returns {Promise<any>} Parsed JSON body, or null for empty responses
26
+ * @throws {ApiError}
27
+ */
28
+ export async function apiFetch(path, options = {}) {
29
+ const { json, headers, ...rest } = options;
30
+ const requestHeaders = new Headers(headers);
31
+ if (!requestHeaders.has("Accept")) requestHeaders.set("Accept", "application/json");
32
+ if (!requestHeaders.has("x-trace-id")) requestHeaders.set("x-trace-id", crypto.randomUUID());
33
+ const init = { ...rest, headers: requestHeaders };
34
+ if (json !== undefined) {
35
+ init.body = JSON.stringify(json);
36
+ init.headers.set("Content-Type", "application/json");
37
+ }
38
+ const response = await fetch(`${baseUrl}${path}`, init);
39
+ const text = await response.text();
40
+ let body = null;
41
+ if (text) {
42
+ try {
43
+ body = JSON.parse(text);
44
+ } catch {
45
+ body = null;
46
+ }
47
+ }
48
+ if (!response.ok) throw new ApiError(response.status, body, response.headers.get("x-trace-id"));
49
+ return body;
50
+ }
@@ -0,0 +1,70 @@
1
+ import { ApiError, apiFetch } from "./client";
2
+
3
+ afterEach(() => vi.unstubAllGlobals());
4
+
5
+ function mockFetch(status, body) {
6
+ vi.stubGlobal(
7
+ "fetch",
8
+ vi.fn().mockResolvedValue({
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ headers: new Headers({ "x-trace-id": "response-trace" }),
12
+ text: () => Promise.resolve(body === undefined ? "" : JSON.stringify(body)),
13
+ }),
14
+ );
15
+ }
16
+
17
+ describe("apiFetch", () => {
18
+ it("generates a separate UUID for each request", async () => {
19
+ mockFetch(200, {});
20
+ await apiFetch("/health");
21
+ await apiFetch("/health");
22
+ const ids = fetch.mock.calls.map(([, init]) => new Headers(init.headers).get("x-trace-id"));
23
+ expect(ids[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
24
+ expect(ids[1]).not.toBe(ids[0]);
25
+ });
26
+ it.each([
27
+ { "X-Trace-Id": "caller-trace", Authorization: "Bearer test" },
28
+ {
29
+ headers: [
30
+ ["X-Trace-Id", "caller-trace"],
31
+ ["Authorization", "Bearer test"],
32
+ ],
33
+ },
34
+ new Headers({ "X-Trace-Id": "caller-trace", Authorization: "Bearer test" }),
35
+ ])("preserves caller headers in all supported forms", async (form) => {
36
+ const headers = form.headers ?? form;
37
+ mockFetch(200, {});
38
+ await apiFetch("/api/v1/items", { headers, method: "POST", json: { name: "test" } });
39
+ const init = fetch.mock.calls[0][1];
40
+ const sent = new Headers(init.headers);
41
+ expect(sent.get("x-trace-id")).toBe("caller-trace");
42
+ expect(sent.get("authorization")).toBe("Bearer test");
43
+ expect(sent.get("accept")).toBe("application/json");
44
+ expect(sent.get("content-type")).toBe("application/json");
45
+ expect(init.body).toBe('{"name":"test"}');
46
+ });
47
+ it("returns parsed JSON on success", async () => {
48
+ mockFetch(200, { items: [] });
49
+ await expect(apiFetch("/api/v1/items")).resolves.toEqual({ items: [] });
50
+ });
51
+ it("throws ApiError with the backend error shape", async () => {
52
+ mockFetch(400, {
53
+ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: ["name"] },
54
+ requestId: "req-1",
55
+ });
56
+ const err = await apiFetch("/api/v1/items").catch((e) => e);
57
+ expect(err).toBeInstanceOf(ApiError);
58
+ expect(err.status).toBe(400);
59
+ expect(err.code).toBe("VALIDATION_ERROR");
60
+ expect(err.message).toBe("Invalid input");
61
+ expect(err.details).toEqual(["name"]);
62
+ expect(err.requestId).toBe("req-1");
63
+ expect(err.traceId).toBe("response-trace");
64
+ });
65
+ it("falls back when the error body is not JSON-shaped", async () => {
66
+ mockFetch(500);
67
+ const err = await apiFetch("/x").catch((e) => e);
68
+ expect(err.code).toBe("UNKNOWN_ERROR");
69
+ });
70
+ });
@@ -0,0 +1,26 @@
1
+ import { apiFetch } from "./client";
2
+
3
+ /**
4
+ * @param {{page?: number, perPage?: number}} [params]
5
+ * @returns {Promise<{items: Array<{id: string, name: string, createdAt: string}>, pagination: object}>}
6
+ */
7
+ export function listItems({ page = 1, perPage = 20 } = {}) {
8
+ const query = new URLSearchParams({ page: String(page), perPage: String(perPage) });
9
+ return apiFetch(`/api/v1/items?${query}`);
10
+ }
11
+
12
+ /**
13
+ * @param {{name: string}} input
14
+ * @returns {Promise<{id: string, name: string, createdAt: string}>}
15
+ */
16
+ export function createItem({ name }) {
17
+ return apiFetch("/api/v1/items", { method: "POST", json: { name } });
18
+ }
19
+
20
+ /**
21
+ * @param {string} id
22
+ * @returns {Promise<{id: string, name: string, createdAt: string}>}
23
+ */
24
+ export function getItem(id) {
25
+ return apiFetch(`/api/v1/items/${encodeURIComponent(id)}`);
26
+ }
@@ -0,0 +1,11 @@
1
+ import { apiFetch } from "./client";
2
+
3
+ /** @returns {Promise<object>} Liveness response from `GET /health`. */
4
+ export function getHealth() {
5
+ return apiFetch("/health");
6
+ }
7
+
8
+ /** @returns {Promise<object>} Readiness response from `GET /ready` (includes database check). */
9
+ export function getReady() {
10
+ return apiFetch("/ready");
11
+ }
@@ -0,0 +1,20 @@
1
+ import { formatCreatedAt } from "@/models/items";
2
+
3
+ /** @param {{items: Array<{id: string, name: string, createdAt: string}>}} props */
4
+ export default function ItemList({ items }) {
5
+ if (items.length === 0) {
6
+ return <p className="text-sm text-muted-foreground">No items yet.</p>;
7
+ }
8
+ return (
9
+ <ul className="divide-y">
10
+ {items.map((item) => (
11
+ <li key={item.id} className="flex items-center justify-between py-2 text-sm">
12
+ <span>{item.name}</span>
13
+ <time className="text-muted-foreground" dateTime={item.createdAt}>
14
+ {formatCreatedAt(item.createdAt)}
15
+ </time>
16
+ </li>
17
+ ))}
18
+ </ul>
19
+ );
20
+ }
@@ -0,0 +1,18 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import ItemList from "./ItemList";
3
+
4
+ describe("ItemList", () => {
5
+ it("renders item names", () => {
6
+ const items = [
7
+ { id: "1", name: "Alpha", createdAt: "2026-10-08T12:00:00Z" },
8
+ { id: "2", name: "Beta", createdAt: "2026-10-08T13:00:00Z" },
9
+ ];
10
+ render(<ItemList items={items} />);
11
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
12
+ expect(screen.getByText("Beta")).toBeInTheDocument();
13
+ });
14
+ it("renders an empty state", () => {
15
+ render(<ItemList items={[]} />);
16
+ expect(screen.getByText("No items yet.")).toBeInTheDocument();
17
+ });
18
+ });
@@ -0,0 +1,35 @@
1
+ import { NavLink } from "react-router";
2
+ import { buttonVariants } from "@/components/ui/button";
3
+
4
+ const NAV = [
5
+ { to: "/", label: "Welcome", end: true },
6
+ { to: "/items", label: "Items", end: false },
7
+ ];
8
+
9
+ /** @param {{appName: string, children: import("react").ReactNode}} props */
10
+ export default function Layout({ appName, children }) {
11
+ return (
12
+ <div className="min-h-svh">
13
+ <header className="border-b">
14
+ <div className="mx-auto flex max-w-3xl items-center justify-between p-4">
15
+ <span className="font-semibold">{appName}</span>
16
+ <nav aria-label="Main" className="flex gap-1">
17
+ {NAV.map((entry) => (
18
+ <NavLink
19
+ key={entry.to}
20
+ to={entry.to}
21
+ end={entry.end}
22
+ className={({ isActive }) =>
23
+ buttonVariants({ variant: isActive ? "secondary" : "ghost" })
24
+ }
25
+ >
26
+ {entry.label}
27
+ </NavLink>
28
+ ))}
29
+ </nav>
30
+ </div>
31
+ </header>
32
+ <main className="mx-auto max-w-3xl p-6">{children}</main>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,22 @@
1
+ import { cn } from "@/lib/utils";
2
+
3
+ const STYLES = {
4
+ ok: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
5
+ error: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
6
+ pending: "bg-muted text-muted-foreground",
7
+ };
8
+ const LABELS = { ok: "OK", error: "Failed", pending: "Checking..." };
9
+
10
+ /** @param {{state: "ok" | "error" | "pending"}} props */
11
+ export default function StatusBadge({ state }) {
12
+ return (
13
+ <span
14
+ className={cn(
15
+ "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
16
+ STYLES[state],
17
+ )}
18
+ >
19
+ {LABELS[state]}
20
+ </span>
21
+ );
22
+ }
@@ -0,0 +1,19 @@
1
+ /** @param {{steps: Array<{label: string, path: string, description: string}>}} props */
2
+ export default function StepList({ steps }) {
3
+ return (
4
+ <ol className="space-y-4">
5
+ {steps.map((step, index) => (
6
+ <li key={step.label} className="flex gap-3">
7
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
8
+ {index + 1}
9
+ </span>
10
+ <div className="min-w-0 space-y-1">
11
+ <p className="text-sm font-medium">{step.label}</p>
12
+ <code className="block text-xs break-words text-muted-foreground">{step.path}</code>
13
+ <p className="text-sm text-muted-foreground">{step.description}</p>
14
+ </div>
15
+ </li>
16
+ ))}
17
+ </ol>
18
+ );
19
+ }
@@ -0,0 +1,52 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button";
2
+ import { cva } from "class-variance-authority";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const buttonVariants = cva(
6
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
11
+ outline:
12
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
13
+ secondary:
14
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
15
+ ghost:
16
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
17
+ destructive:
18
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
19
+ link: "text-primary underline-offset-4 hover:underline",
20
+ },
21
+ size: {
22
+ default:
23
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
24
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
25
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
26
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
27
+ icon: "size-8",
28
+ "icon-xs":
29
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
30
+ "icon-sm":
31
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
32
+ "icon-lg": "size-9",
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: "default",
37
+ size: "default",
38
+ },
39
+ },
40
+ );
41
+
42
+ function Button({ className, variant = "default", size = "default", ...props }) {
43
+ return (
44
+ <ButtonPrimitive
45
+ data-slot="button"
46
+ className={cn(buttonVariants({ variant, size, className }))}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
52
+ export { Button, buttonVariants };
@@ -0,0 +1,83 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ function Card({ className, size = "default", ...props }) {
5
+ return (
6
+ <div
7
+ data-slot="card"
8
+ data-size={size}
9
+ className={cn(
10
+ "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function CardHeader({ className, ...props }) {
19
+ return (
20
+ <div
21
+ data-slot="card-header"
22
+ className={cn(
23
+ "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ function CardTitle({ className, ...props }) {
32
+ return (
33
+ <div
34
+ data-slot="card-title"
35
+ className={cn(
36
+ "font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
37
+ className,
38
+ )}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function CardDescription({ className, ...props }) {
45
+ return (
46
+ <div
47
+ data-slot="card-description"
48
+ className={cn("text-sm text-muted-foreground", className)}
49
+ {...props}
50
+ />
51
+ );
52
+ }
53
+
54
+ function CardAction({ className, ...props }) {
55
+ return (
56
+ <div
57
+ data-slot="card-action"
58
+ className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
59
+ {...props}
60
+ />
61
+ );
62
+ }
63
+
64
+ function CardContent({ className, ...props }) {
65
+ return (
66
+ <div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />
67
+ );
68
+ }
69
+
70
+ function CardFooter({ className, ...props }) {
71
+ return (
72
+ <div
73
+ data-slot="card-footer"
74
+ className={cn(
75
+ "flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
76
+ className,
77
+ )}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
@@ -0,0 +1,19 @@
1
+ import * as React from "react";
2
+ import { Input as InputPrimitive } from "@base-ui/react/input";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ function Input({ className, type, ...props }) {
6
+ return (
7
+ <InputPrimitive
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ }
18
+
19
+ export { Input };
@@ -0,0 +1,33 @@
1
+ import { useEffect, useState } from "react";
2
+ import { getHealth, getReady } from "@/api/system";
3
+
4
+ const PENDING = { state: "pending", body: null, message: null };
5
+
6
+ function toResult(settled) {
7
+ if (settled.status === "fulfilled") {
8
+ return { state: "ok", body: settled.value, message: null };
9
+ }
10
+ return { state: "error", body: null, message: settled.reason?.message ?? "Request failed" };
11
+ }
12
+
13
+ /**
14
+ * Checks `/health` and `/ready` once on mount.
15
+ * @returns {{health: StatusResult, ready: StatusResult, unreachable: boolean}}
16
+ * where StatusResult is `{state: "pending" | "ok" | "error", body: object | null, message: string | null}`.
17
+ */
18
+ export function useBackendStatus() {
19
+ const [status, setStatus] = useState({ health: PENDING, ready: PENDING });
20
+
21
+ useEffect(() => {
22
+ let active = true;
23
+ Promise.allSettled([getHealth(), getReady()]).then(([health, ready]) => {
24
+ if (active) setStatus({ health: toResult(health), ready: toResult(ready) });
25
+ });
26
+ return () => {
27
+ active = false;
28
+ };
29
+ }, []);
30
+
31
+ const unreachable = status.health.state === "error" || status.ready.state === "error";
32
+ return { ...status, unreachable };
33
+ }
@@ -0,0 +1,47 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { createItem, listItems } from "@/api/items";
3
+ import { validateItemName } from "@/models/items";
4
+
5
+ /** Coordinates loading and creating items with loading/error state. */
6
+ export function useItems() {
7
+ const [items, setItems] = useState([]);
8
+ const [loading, setLoading] = useState(true);
9
+ const [error, setError] = useState(null);
10
+
11
+ const load = useCallback(async () => {
12
+ setLoading(true);
13
+ setError(null);
14
+ try {
15
+ const data = await listItems();
16
+ setItems(data.items);
17
+ } catch (err) {
18
+ setError(err.message);
19
+ } finally {
20
+ setLoading(false);
21
+ }
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ // eslint-disable-next-line react-hooks/set-state-in-effect
26
+ load();
27
+ }, [load]);
28
+
29
+ const create = useCallback(async (name) => {
30
+ const check = validateItemName(name);
31
+ if (!check.ok) {
32
+ setError(check.message);
33
+ return false;
34
+ }
35
+ setError(null);
36
+ try {
37
+ const created = await createItem({ name: name.trim() });
38
+ setItems((current) => [created, ...current]);
39
+ return true;
40
+ } catch (err) {
41
+ setError(err.message);
42
+ return false;
43
+ }
44
+ }, []);
45
+
46
+ return { items, loading, error, create, reload: load };
47
+ }