diff --git a/.env.example b/.env.example index 8364691..f4e7ac1 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ # GitHub OAuth App client ID (public — safe to embed in the SPA bundle). +# Use the dev OAuth App's id locally; the dev API is configured with the same id. VITE_GITHUB_OAUTH_CLIENT_ID= -# Base URL of the auth-function broker that exchanges OAuth codes for tokens. -# Example (local dev): http://localhost:7071 -# Example (prod): https://atk-auth.example.com -VITE_AUTH_FUNCTION_URL= +# Base URL of the ATK API (no /api prefix, no trailing slash). +# Example (dev): https://func-atk-dev.azurewebsites.net +# Example (local): http://localhost:7071 +# Example (prod): https://func-atk-prod.azurewebsites.net +VITE_ATK_API_URL= diff --git a/.github/workflows/deploy-auth-function.yml b/.github/workflows/deploy-auth-function.yml deleted file mode 100644 index 0211f3c..0000000 --- a/.github/workflows/deploy-auth-function.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Deploy Auth Function - -on: - push: - branches: [main] - paths: - - 'auth-function/**' - - '.github/workflows/deploy-auth-function.yml' - workflow_dispatch: - -permissions: - contents: read - id-token: write - -concurrency: - group: deploy-auth-function - cancel-in-progress: false - -jobs: - deploy: - name: Build, Validate & Deploy - runs-on: ubuntu-latest - environment: - name: auth-function-prod - defaults: - run: - working-directory: auth-function - - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: 10.5.2 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - cache-dependency-path: auth-function/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - - name: Type check - run: pnpm typecheck - - - name: Test - run: pnpm test - - - name: Build - run: pnpm build - - - name: Prune to production dependencies - run: pnpm prune --prod - - - name: Azure login - uses: azure/login@v2 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - - name: Deploy to Azure Functions - uses: Azure/functions-action@v1 - with: - app-name: ${{ vars.AZURE_FUNCTION_APP_NAME }} - package: auth-function - - - name: Smoke test /api/health - working-directory: . - env: - FUNCTION_APP_NAME: ${{ vars.AZURE_FUNCTION_APP_NAME }} - run: | - set -euo pipefail - HEALTH_URL="https://${FUNCTION_APP_NAME}.azurewebsites.net/api/health" - echo "Probing ${HEALTH_URL}" - for attempt in 1 2 3 4 5 6; do - status=$(curl -sS -o response.json -w '%{http_code}' --max-time 15 "${HEALTH_URL}" || echo "000") - if [ "${status}" = "200" ]; then - echo "Health check passed (attempt ${attempt})" - cat response.json - echo - exit 0 - fi - echo "Attempt ${attempt} returned status ${status}; retrying in 10s..." - sleep 10 - done - echo "Health check failed after 6 attempts." - cat response.json || true - exit 1 - - - name: Azure logout - if: always() - run: az logout - working-directory: . diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 2e5572b..7690e5c 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -41,22 +41,6 @@ jobs: - name: Test web run: pnpm test - - name: Install auth-function dependencies - working-directory: auth-function - run: pnpm install --frozen-lockfile - - - name: Lint auth-function - working-directory: auth-function - run: pnpm lint - - - name: Type check auth-function - working-directory: auth-function - run: pnpm typecheck - - - name: Test auth-function - working-directory: auth-function - run: pnpm test - build: needs: validate runs-on: ubuntu-latest @@ -77,7 +61,7 @@ jobs: - name: Build env: VITE_GITHUB_OAUTH_CLIENT_ID: ${{ vars.VITE_GITHUB_OAUTH_CLIENT_ID }} - VITE_AUTH_FUNCTION_URL: ${{ vars.VITE_AUTH_FUNCTION_URL }} + VITE_ATK_API_URL: ${{ vars.VITE_ATK_API_URL }} run: pnpm build - uses: actions/upload-pages-artifact@v3 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 733f28e..7e3cfee 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -49,19 +49,3 @@ jobs: - name: Run tests run: pnpm test - - - name: Install auth-function dependencies - working-directory: auth-function - run: pnpm install --frozen-lockfile - - - name: Lint auth-function - working-directory: auth-function - run: pnpm lint - - - name: Type check auth-function - working-directory: auth-function - run: pnpm typecheck - - - name: Test auth-function - working-directory: auth-function - run: pnpm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..dcc976c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Generated by `pnpm generate-api`; do not format by hand. +src/lib/api/ diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md index 98179ba..db25e07 100644 --- a/PROJECT_OVERVIEW.md +++ b/PROJECT_OVERVIEW.md @@ -13,8 +13,9 @@ There are three repositories that make up the Agentic Toolkit ecosystem: | Repository | Role | Audience | |---|---|---| | `agentic-toolkit-registry` | The registry — where all skills, agents, rules, hooks, memory templates, and MCP configs live | Content store | -| `agentic-toolkit` | The `atk` CLI — developer tool for installing/managing assets in local projects | Developers | +| `agentic-toolkit-cli` | The `atk` CLI — developer tool for installing/managing assets in local projects | Developers | | `agentic-toolkit-web` *(this project)* | The web UI — browse registry assets and contribute new ones through a browser | **Non-technical users** (Product Managers, Technical Product Managers, Product Owners, designers, etc.) | +| `Emergent.AgenticToolkit` (Azure DevOps) | The **ATK API** — the .NET API both the CLI and the web app talk to for registry reads, downloads, publishing, and the web sign-in exchange | Platform | ## 2. Purpose & Audience @@ -45,7 +46,7 @@ Developers should continue to use the `atk` CLI. ATK Web deliberately does **not - Fields for name, description, README, type, tags. - On submit, opens a **pull request** against `agentic-toolkit-registry` (mirroring the CLI's `atk publish` flow) so the existing security review pipeline runs. - **Org support** — users can view assets scoped to their org or global assets. -- **Auth via GitHub** (needed to open PRs against the private registry repo; read access may also require auth depending on registry visibility). +- **Auth via GitHub** (the ATK API validates the token, gates on EmergentSoftware org membership, and opens PRs with it so they are authored by the user). ### Out of scope @@ -54,11 +55,11 @@ Developers should continue to use the `atk` CLI. ATK Web deliberately does **not - Tool adapter placement logic (the web app doesn't place files into a project; it only downloads raw asset content). - Bundle management (initially — may be added later; browse/download of bundles could be a fast follow). - Editing existing assets in-place (MVP is create-new-only; edits can go through the CLI or PRs directly). -- Server-side logic — this is a static site; all interactions happen in the browser against GitHub's API. +- Server-side logic in this repo — this is a static site; every registry interaction goes through the shared ATK API, which lives in the `Emergent.AgenticToolkit` monorepo. ## 4. Tech Stack -Intentionally small and conventional. No server, no backend — this is a fully static SPA deployed to GitHub Pages. +Intentionally small and conventional. No server in this repo — this is a fully static SPA deployed to GitHub Pages that talks to the shared ATK API. ### Core - **Vite** — build tool and dev server. @@ -73,12 +74,12 @@ Intentionally small and conventional. No server, no backend — this is a fully ### Data & Forms - **TanStack Table** — asset browse/list view with sorting, filtering, column visibility. - **TanStack Form** — the "Contribute asset" form. -- **TanStack Query** — data fetching, caching, and loading states against the GitHub API. +- **TanStack Query** — data fetching, caching, and loading states against the ATK API. - **Zod** — schema validation for form inputs and for parsing registry data. **Reuse the Zod schemas exported from the `agentic-toolkit` CLI** (`scripts/export-schemas.ts` produces JSON Schema; ideally we vendor or publish the Zod schemas so the web app validates manifests identically to the CLI). -### GitHub Integration -- **Octokit** (`@octokit/rest`) — read the registry, create branches, commit files, and open PRs. -- Auth via a **GitHub OAuth App** (standard web flow), with the `code`-for-token exchange handled by a tiny **Azure Function** (see §7 Authentication). +### ATK API Integration +- **`@hey-api/openapi-ts`** — generates a typed fetch client (`src/lib/api/`) from the API's vendored OpenAPI contract (`openapi/openapi.json`). `src/lib/api-client.ts` wraps it with the base URL (`VITE_ATK_API_URL`), bearer auth from the session token, retries, and error mapping. +- Auth via a **GitHub OAuth App** (standard web flow), with the `code`-for-token exchange handled by the ATK API's `POST /auth/github/exchange` (see §7 Authentication). ### Tooling - **ESLint** + **Prettier** — match the conventions used in `agentic-toolkit`. @@ -94,7 +95,7 @@ A filterable, searchable table/grid of all assets in the registry. Columns inclu - Click into an asset to see full details and the rendered README. ### Asset detail view -Shows the full manifest info plus a rendered Markdown README. Primary action is a **Download** button that packages the asset's files into a zip (or downloads the folder directly via the File System Access API where supported) and hands them to the user. +Shows the full manifest info, the file listing from the API, and a rendered Markdown README. Primary action is a **Download** button that fetches a zip (or `.skill` archive) built by the ATK API — the asset plus its transitive dependencies — and hands it to the user. ### Contribute flow (`atk publish` equivalent) A guided form where a non-technical user can: @@ -104,57 +105,52 @@ A guided form where a non-technical user can: 4. Select org scope (their org vs. global, where permitted). 5. Submit. -On submit, the app uses the signed-in user's GitHub credentials (via Octokit directly from the browser) to: -1. **Fork** `EmergentSoftware/agentic-toolkit-registry` into the user's personal GitHub account — or reuse their existing fork if one is already present. -2. **Create a new branch** on the fork (e.g. `contribute/--`). -3. **Commit** the asset files and generated manifest to that branch. -4. **Open a pull request** from the user's fork branch back to the registry's default branch. +On submit, the app sends the manifest and files to the ATK API's `POST /publish` (with `client: "web"`). The API validates the payload against the registry's JSON Schemas and rules, then — **using the signed-in user's own GitHub token** — creates a branch on `EmergentSoftware/agentic-toolkit-registry`, commits the files under `assets/{type}s/[@{org}/]{name}/{version}/`, and opens a pull request authored by the user. `?dryRun=1` sends the same payload to `POST /publish/plan`, which validates and returns the plan without touching GitHub. -This is the same fork → branch → commit → PR pattern the `atk publish` CLI command uses. It works for **any EmergentSoftware org member** regardless of whether they have direct write access to the registry, since contributions always go through a personal fork. +This is the same path the `atk publish` CLI command uses, so the branch, path, PR title, and body conventions are identical. The PR then runs through the **existing security review pipeline** in `agentic-toolkit-registry`. Assets are **never** merged directly — maintainer review is mandatory. The UI should make this clear to the user ("Your contribution will be reviewed before it appears in the registry") and show a success screen with a direct link to the opened PR. ### Org awareness -The app reads the user's GitHub org memberships and lets them scope browsing and publishing to their org. This mirrors the org field in the CLI's lockfile. +Assets and bundles may be org-scoped (`org` in the manifest; `@{org}/` in registry paths and `?org=` on API calls). The app lets users browse by org scope, publish org-scoped assets, and create org-scoped bundles. This mirrors the org field in the CLI's lockfile. ## 6. Architecture - **Static SPA.** Built with Vite, deployed to GitHub Pages via GitHub Actions. -- **GitHub API as the backend.** Reads from `agentic-toolkit-registry` via the Contents API (same mechanism the CLI uses). Writes via the standard fork/branch/commit/PR flow — all done from the browser using Octokit. -- **One tiny Azure Function for auth only.** Its sole responsibility is exchanging the OAuth `code` for an access token (see §7). It is not an API proxy — all registry reads and writes go directly from the browser to GitHub. -- **Tokens live in the browser.** Access tokens are held in `sessionStorage` and never persisted to any server we operate. The Azure Function does not store tokens; it just brokers the handshake. +- **The ATK API as the backend.** Every registry read (`GET /registry`, manifests, READMEs, file listings), every download (server-built zips), and every publish goes through the shared API; the browser never talks to GitHub's REST API directly. The CLI uses the same endpoints, so both clients see identical behaviour. +- **Token passthrough.** The API validates the user's GitHub token and EmergentSoftware membership on each request and opens publish PRs with that same token, so PR authorship and the review workflow are unchanged from the CLI. The API never persists user tokens. +- **Tokens live in the browser.** Access tokens are held in `sessionStorage` and never persisted to any server we operate. - **Registry schema parity with the CLI.** The web app validates and renders manifests using the same Zod schemas defined in `agentic-toolkit/src/lib/schemas/`. A valid asset in the CLI is a valid asset in the web UI, and vice versa. - **No duplicate registry.** The web app reads the canonical `registry.json` published by the registry repo's CI — the same artifact the CLI consumes. ## 7. Authentication -### Approach: GitHub OAuth App + Azure Function token-exchange proxy +### Approach: GitHub OAuth App + ATK API token exchange -GitHub Pages is static-only, and GitHub's OAuth token-exchange endpoint does not support CORS from arbitrary browser origins. That rules out a pure-browser OAuth handshake. The minimum viable solution is a **tiny auth proxy** that holds the OAuth App's `client_secret` and handles the one `code`-for-token exchange. We are hosting this proxy as an **Azure Function** to match existing EmergentSoftware infra. +GitHub Pages is static-only, and GitHub's OAuth token-exchange endpoint does not support CORS from arbitrary browser origins. That rules out a pure-browser OAuth handshake. The ATK API holds the OAuth App's `client_secret` and performs the one `code`-for-token exchange at `POST /auth/github/exchange` (this replaced the repo's earlier standalone `auth-function`). ### Components 1. **GitHub OAuth App** registered under the EmergentSoftware org. - Callback URL: the deployed GitHub Pages URL. - Required scopes: `read:org` (to verify EmergentSoftware membership) and `repo` (to read the private registry, fork it, push to the user's fork, and open PRs). -2. **Azure Function** (Consumption plan, Node.js/TypeScript). - - Single HTTP-triggered function: `POST /api/auth/exchange`. - - Accepts an OAuth `code`, calls `github.com/login/oauth/access_token` with the stored `client_secret`, returns the resulting access token to the SPA. - - `client_secret` lives in Function App application settings. - - CORS restricted to the GitHub Pages origin. - - Free tier is more than sufficient (1M requests/month). Cold starts of 1–3s are acceptable for a once-per-session event. +2. **ATK API** (`func-atk-prod` / `func-atk-dev`, .NET on Azure Functions; deployed from the monorepo). + - `POST /auth/github/exchange` accepts an OAuth `code`, calls `github.com/login/oauth/access_token` with the stored `client_secret`, and returns GitHub's token response verbatim. + - The client secret lives in Key Vault; CORS is restricted to the SPA origins. + - Two OAuth Apps: the **dev** app's id is configured on the dev API (used by `pnpm dev`), the **prod** app's id on the prod API (used by GitHub Pages). 3. **SPA auth flow.** - User clicks "Sign in with GitHub" → redirected to the OAuth App authorize screen. - GitHub redirects back to the SPA with a `code`. - - SPA `POST`s the code to the Azure Function → receives the access token. - - SPA stores the token in `sessionStorage` and uses Octokit directly for all subsequent GitHub API calls (those endpoints support CORS for authenticated requests). + - SPA `POST`s the code to `{VITE_ATK_API_URL}/auth/github/exchange` → receives the access token. + - SPA stores the token in `sessionStorage` and sends it as `Authorization: Bearer …` on every ATK API call. ### Org membership gate -Immediately after auth, the SPA calls `GET /orgs/EmergentSoftware/members/{username}` to verify the user is a member of the EmergentSoftware GitHub organization. +Immediately after auth, the SPA calls the API's `GET /me`. The API validates the token and checks EmergentSoftware membership itself: -- **Member:** proceeds into the app. -- **Non-member:** shown a friendly blocking screen explaining they must be a member of EmergentSoftware to use this tool, with contact guidance for being added. +- **`200`:** active member — proceeds into the app; the response supplies the login, name, and avatar for display. +- **`403 not_org_member` / `org_membership_unverifiable`:** shown a friendly blocking screen explaining they must be a member of EmergentSoftware to use this tool, with contact guidance for being added (the unverifiable case logs SAML / OAuth-App-approval hints to the console). +- **`401`:** the stored token is dead; the app returns to the signed-out landing. ### End-user prerequisites @@ -168,30 +164,32 @@ No PATs, no CLI, no terminal, no git knowledge required. ## 8. Deployment -- **Web app:** GitHub Pages hosted from `EmergentSoftware/agentic-toolkit-web`. GitHub Actions pipeline builds the Vite app on push to `main` and publishes to Pages. -- **Auth function:** deployed to Azure via GitHub Actions. Can live in the same repo under `/auth-function` or in a sibling repo — either works; same-repo is simpler for MVP. -- **Branching:** match the other ATK repos' `develop` → `main` convention for consistency. +- **Web app:** GitHub Pages hosted from `EmergentSoftware/agentic-toolkit-web`. GitHub Actions pipeline builds the Vite app on push to `main` (with `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_ATK_API_URL` from repo variables) and publishes to Pages. Merging to `main` is the production deploy. +- **ATK API:** deployed from the `Emergent.AgenticToolkit` monorepo via Azure Pipelines; nothing in this repo deploys it. +- **Branching:** feature branches off `main`, PRs to `main`. ## 9. Repository Layout (proposed) ``` agentic-toolkit-web/ +├── openapi/openapi.json # vendored ATK API contract (pnpm refresh-openapi) +├── openapi-ts.config.ts # @hey-api/openapi-ts config (pnpm generate-api) ├── src/ -│ ├── components/ # shadcn/ui components + app components -│ ├── routes/ # page components (browse, detail, contribute) +│ ├── components/ # shadcn/ui components + app components +│ ├── routes/ # page components (browse, detail, contribute, bundles) │ ├── lib/ -│ │ ├── github.ts # Octokit client, auth, PR creation -│ │ ├── registry.ts # fetch + parse registry.json -│ │ └── schemas/ # Zod schemas (vendored from agentic-toolkit) -│ ├── hooks/ +│ │ ├── api/ # GENERATED typed client + types (do not edit) +│ │ ├── api-client.ts # base URL, bearer auth, retries, error mapping +│ │ ├── session.ts # OAuth redirect + code exchange helpers +│ │ ├── registry-client.ts # registry index, manifests, READMEs, file listings +│ │ ├── download-service.ts # server-built zip / .skill downloads +│ │ ├── publish-service.ts # POST /publish and /publish/plan payloads +│ │ └── schemas/ # Zod schemas (vendored from agentic-toolkit-cli) +│ ├── hooks/ # TanStack Query hooks (useRegistry, useAssetFiles, …) +│ ├── providers/ # SessionProvider (token, GET /me, status machine) │ └── main.tsx -├── auth-function/ # Azure Function for OAuth token exchange -│ ├── src/ -│ │ └── exchange.ts # POST /api/auth/exchange handler -│ ├── host.json -│ └── package.json ├── public/ -├── .github/workflows/ # build + deploy web; deploy auth function +├── .github/workflows/ # validate PRs; build + deploy Pages on main ├── vite.config.ts ├── tsconfig.json ├── eslint.config.js @@ -206,7 +204,7 @@ These are intentionally unresolved — they need a call before or during impleme 2. **Download format.** Zip (via `jszip`) is universal. The File System Access API offers nicer UX on Chromium but needs a fallback. MVP recommendation: zip download. 3. **Versioning / release process.** Does this repo need `semantic-release` like the CLI, or is trunk-based "deploy on merge to main" enough for a static site? 4. **Bundles in MVP?** The registry has bundles (curated groups of assets). Recommend: read-only bundle browsing in MVP, no bundle authoring. -5. **Auth function location.** In-repo under `/auth-function` (simpler) or separate repo (cleaner separation of concerns)? Lean toward in-repo for MVP. +5. ~~**Auth function location.**~~ Resolved: the code exchange moved into the shared ATK API (2026-09); the in-repo `auth-function` was retired. ## 11. Success Criteria for MVP diff --git a/auth-function/.funcignore b/auth-function/.funcignore deleted file mode 100644 index 20a4816..0000000 --- a/auth-function/.funcignore +++ /dev/null @@ -1,18 +0,0 @@ -.git* -.vscode -local.settings.json -local.settings.json.template -test -tests -**/*.test.ts -**/*.test.js -**/__tests__/** -vitest.config.ts -eslint.config.js -tsconfig.json -.eslintrc* -.prettierrc* -*.md -README.md -coverage -.nyc_output diff --git a/auth-function/.gitignore b/auth-function/.gitignore deleted file mode 100644 index a6b3e73..0000000 --- a/auth-function/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -dist/ -local.settings.json -.vscode/ -*.log diff --git a/auth-function/.npmrc b/auth-function/.npmrc deleted file mode 100644 index d67f374..0000000 --- a/auth-function/.npmrc +++ /dev/null @@ -1 +0,0 @@ -node-linker=hoisted diff --git a/auth-function/README.md b/auth-function/README.md deleted file mode 100644 index 7c6dcfc..0000000 --- a/auth-function/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# ATK Auth Function - -An Azure Function that brokers the GitHub OAuth code-for-token exchange for the -ATK Web SPA. The browser cannot call -`https://github.com/login/oauth/access_token` directly because that endpoint -requires the OAuth app's `client_secret`. This function holds the secret in -Function App application settings and exposes a single endpoint: - -``` -POST /api/auth/exchange -``` - -## Endpoint contract - -**Request** - -```http -POST /api/auth/exchange -Content-Type: application/json - -{ "code": "" } -``` - -**Success response** (200) - -```json -{ - "access_token": "gho_...", - "scope": "read:org,repo", - "token_type": "bearer" -} -``` - -The function forwards GitHub's response body as-is. Exact fields are whatever -GitHub returns on a successful exchange. - -**Error envelope** (typed) - -```json -{ "error": "", "message": "" } -``` - -| Status | `error` | When | -| ------ | ----------------------------- | ---------------------------------------------------------- | -| 400 | `invalid_json` | Request body was not valid JSON | -| 400 | `invalid_request` | Zod validation of the body failed (e.g., missing `code`) | -| 400 | `` | GitHub returned an OAuth error (e.g., `bad_verification_code`) | -| 403 | `origin_not_allowed` | Request `Origin` is not in the CORS allow-list | -| 405 | `method_not_allowed` | Method was not `POST` or `OPTIONS` | -| 500 | `server_misconfigured` | `GITHUB_OAUTH_CLIENT_ID`/`GITHUB_OAUTH_CLIENT_SECRET` unset | -| 502 | `upstream_unavailable` | `fetch` to GitHub threw (DNS, connection, TLS) | -| 502 | `upstream_invalid_response` | GitHub responded with non-JSON | -| 502 | `upstream_error` | GitHub returned a non-2xx HTTP status | - -## Application settings - -Set these in Azure Function App configuration (for deployed environments) or in -`local.settings.json` (for local development). Copy -`local.settings.json.template` to `local.settings.json` — the latter is -`.gitignore`d and must never be committed. - -| Key | Required | Description | -| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------ | -| `GITHUB_OAUTH_CLIENT_ID` | yes | Client ID of the GitHub OAuth App that the SPA redirects users to. | -| `GITHUB_OAUTH_CLIENT_SECRET` | yes | Client secret for the same OAuth App. Never commit. Rotate via the GitHub OAuth App settings. | -| `CORS_ALLOWED_ORIGINS` | yes | Comma-separated list of origins allowed to call the function. `http://localhost:5173` is always implicitly allowed to simplify local dev. | -| `FUNCTIONS_WORKER_RUNTIME` | yes | Must be `node`. | -| `FUNCTIONS_NODE_VERSION` | yes | Must be `~20`. | - -## Local development - -### Prerequisites - -- Node.js 20 LTS (matches the Function App runtime) -- [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) -- `pnpm` 10.5.2 (pinned via `packageManager` in `package.json`) -- A **dev GitHub OAuth App** registered out-of-band on your personal account. - Use these settings: - - Homepage URL: `http://localhost:5173` - - Authorization callback URL: `http://localhost:5173/auth/callback` - - Note the Client ID and generate a Client Secret; you will paste both into - `local.settings.json`. - -The dev OAuth App is yours, not a shared organization resource. Do not check -its secret into any repository. - -### Setup - -```bash -cd auth-function -pnpm install -cp local.settings.json.template local.settings.json -# edit local.settings.json — fill in GITHUB_OAUTH_CLIENT_ID and -# GITHUB_OAUTH_CLIENT_SECRET from your dev GitHub OAuth App. -pnpm build -func start -``` - -`func start` boots the Functions host on `http://localhost:7071`, exposing: - -``` -POST http://localhost:7071/api/auth/exchange -``` - -You can smoke-test a validation error without GitHub: - -```bash -curl -i -X POST http://localhost:7071/api/auth/exchange \ - -H 'Origin: http://localhost:5173' \ - -H 'Content-Type: application/json' \ - -d '{"code":""}' -``` - -### Scripts - -| Script | What it does | -| ----------------- | ----------------------------------------------------------------------- | -| `pnpm build` | Compile TypeScript to `dist/` | -| `pnpm watch` | Compile in watch mode | -| `pnpm start` | Clean, build, then `func start` (Azure Functions Core Tools) | -| `pnpm lint` | Run ESLint over `src/` | -| `pnpm typecheck` | `tsc --noEmit` | -| `pnpm test` | Run Vitest unit tests (handler is mock-tested with `fetch` stubbed) | -| `pnpm test:watch` | Vitest in watch mode | - -## Logging - -The function logs one structured JSON line per event via `context.log`. -Sensitive fields are redacted to `[REDACTED]` before emission. The redaction -list is: - -- `code` -- `access_token` -- `refresh_token` -- `client_secret` -- `authorization` (case-insensitive header name) - -Redaction applies recursively to any logged object, including upstream error -bodies. Nothing in this list is ever returned to the caller either — only the -typed error envelope above. - -## SPA integration (Phase 8) - -Phase 8 of the ATK Web phased plan wires the SPA callback handler to this -function. The SPA will read the function URL from a Vite env var: - -``` -# .env.local (SPA, Phase 8 — do NOT set in this package) -VITE_AUTH_FUNCTION_URL=http://localhost:7071/api/auth/exchange -``` - -In production the same variable will point at the deployed Function App, -e.g. `https://atk-auth.azurewebsites.net/api/auth/exchange`. The SPA is -expected to POST `{ "code": "..." }` with `Content-Type: application/json` -and an `Origin` header matching one of the `CORS_ALLOWED_ORIGINS` entries. -The function never sets cookies and the SPA must store the returned token -only in `sessionStorage` (see Phase 8 scope). - -## Deployment - -Out of scope for Phase 7. Production deployment to Azure is handled in -Phase 11 of the phased plan. diff --git a/auth-function/eslint.config.js b/auth-function/eslint.config.js deleted file mode 100644 index 6717b25..0000000 --- a/auth-function/eslint.config.js +++ /dev/null @@ -1,32 +0,0 @@ -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import perfectionist from 'eslint-plugin-perfectionist'; -import vitest from '@vitest/eslint-plugin'; -import eslintConfigPrettier from 'eslint-config-prettier'; - -export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - perfectionist.configs['recommended-natural'], - { - files: ['src/**/*.ts'], - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, - ], - }, - }, - { - files: ['**/*.test.ts', '**/__tests__/**/*.ts'], - ...vitest.configs.recommended, - rules: { - ...vitest.configs.recommended.rules, - '@typescript-eslint/no-explicit-any': 'off', - }, - }, - { - ignores: ['dist/**', 'node_modules/**', '*.js', '*.cjs'], - }, - eslintConfigPrettier, -); diff --git a/auth-function/host.json b/auth-function/host.json deleted file mode 100644 index 06d01bd..0000000 --- a/auth-function/host.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "excludedTypes": "Request" - } - } - }, - "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[4.*, 5.0.0)" - } -} diff --git a/auth-function/local.settings.json.template b/auth-function/local.settings.json.template deleted file mode 100644 index 93a3c75..0000000 --- a/auth-function/local.settings.json.template +++ /dev/null @@ -1,18 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "", - "FUNCTIONS_WORKER_RUNTIME": "node", - "FUNCTIONS_NODE_VERSION": "~20", - - "GITHUB_OAUTH_CLIENT_ID": "", - "GITHUB_OAUTH_CLIENT_SECRET": "", - - "CORS_ALLOWED_ORIGINS": "http://localhost:5173" - }, - "Host": { - "LocalHttpPort": 7071, - "CORS": "http://localhost:5173", - "CORSCredentials": false - } -} diff --git a/auth-function/package.json b/auth-function/package.json deleted file mode 100644 index b36081b..0000000 --- a/auth-function/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@detergent-software/atk-auth-function", - "version": "0.0.0", - "private": true, - "type": "module", - "description": "Azure Function broker for the ATK Web GitHub OAuth code-for-token exchange.", - "engines": { - "node": ">=20.0.0 <21.0.0" - }, - "packageManager": "pnpm@10.5.2", - "main": "dist/src/index.js", - "scripts": { - "build": "tsc", - "watch": "tsc --watch", - "clean": "rimraf dist", - "prestart": "pnpm run clean && pnpm run build", - "start": "func start", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@azure/functions": "^4.5.0", - "zod": "^4.0.0" - }, - "devDependencies": { - "@eslint/js": "^9.0.0", - "@types/node": "^20.0.0", - "@vitest/eslint-plugin": "^1.0.0", - "eslint": "^9.0.0", - "eslint-config-prettier": "^10.0.0", - "eslint-plugin-perfectionist": "^5.0.0", - "rimraf": "^6.0.0", - "typescript": "^5.7.0", - "typescript-eslint": "^8.0.0", - "vitest": "^4.0.0" - } -} diff --git a/auth-function/pnpm-lock.yaml b/auth-function/pnpm-lock.yaml deleted file mode 100644 index 38dfcb4..0000000 --- a/auth-function/pnpm-lock.yaml +++ /dev/null @@ -1,1798 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@azure/functions': - specifier: ^4.5.0 - version: 4.12.0 - zod: - specifier: ^4.0.0 - version: 4.3.6 - devDependencies: - '@eslint/js': - specifier: ^9.0.0 - version: 9.39.4 - '@types/node': - specifier: ^20.0.0 - version: 20.19.39 - '@vitest/eslint-plugin': - specifier: ^1.0.0 - version: 1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)(vitest@4.1.4(@types/node@20.19.39)(vite@8.0.8(@types/node@20.19.39))) - eslint: - specifier: ^9.0.0 - version: 9.39.4 - eslint-config-prettier: - specifier: ^10.0.0 - version: 10.1.8(eslint@9.39.4) - eslint-plugin-perfectionist: - specifier: ^5.0.0 - version: 5.8.0(eslint@9.39.4)(typescript@5.9.3) - rimraf: - specifier: ^6.0.0 - version: 6.1.3 - typescript: - specifier: ^5.7.0 - version: 5.9.3 - typescript-eslint: - specifier: ^8.0.0 - version: 8.58.2(eslint@9.39.4)(typescript@5.9.3) - vitest: - specifier: ^4.0.0 - version: 4.1.4(@types/node@20.19.39)(vite@8.0.8(@types/node@20.19.39)) - -packages: - - '@azure/functions-extensions-base@0.2.0': - resolution: {integrity: sha512-ncCkHBNQYJa93dBIh+toH0v1iSgCzSo9tr94s6SMBe7DPWREkaWh8cq33A5P4rPSFX1g5W+3SPvIzDr/6/VOWQ==} - engines: {node: '>=18.0'} - - '@azure/functions@4.12.0': - resolution: {integrity: sha512-aHBSvEDHOUhLhkivPiotoYfE6WPZdutv9OnXEkSqYtyWjbf1k/DoQ/Z9swqIbyaBSvh2xOR8ARKj5CR/2jqEOw==} - engines: {node: '>=20.0'} - - '@emnapi/core@1.9.2': - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - - '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@napi-rs/wasm-runtime@1.1.3': - resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.124.0': - resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} - - '@rolldown/binding-android-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': - resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.15': - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@20.19.39': - resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} - - '@typescript-eslint/eslint-plugin@8.58.2': - resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.58.2 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.58.2': - resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.58.2': - resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.58.2': - resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.58.2': - resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.58.2': - resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.58.2': - resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.58.2': - resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.58.2': - resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.58.2': - resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitest/eslint-plugin@1.6.15': - resolution: {integrity: sha512-dTMjrdngmcB+DxomlKQ+SUubCTvd0m2hQQFpv5sx+GRodmeoxr2PVbphk57SVp250vpxphk9Ccwyv6fQ6+2gkA==} - engines: {node: '>=18'} - peerDependencies: - '@typescript-eslint/eslint-plugin': '*' - eslint: '>=8.57.0' - typescript: '>=5.0.0' - vitest: '*' - peerDependenciesMeta: - '@typescript-eslint/eslint-plugin': - optional: true - typescript: - optional: true - vitest: - optional: true - - '@vitest/expect@4.1.4': - resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} - - '@vitest/mocker@4.1.4': - resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.4': - resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} - - '@vitest/runner@4.1.4': - resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} - - '@vitest/snapshot@4.1.4': - resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} - - '@vitest/spy@4.1.4': - resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} - - '@vitest/utils@4.1.4': - resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - es-module-lexer@2.0.0: - resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-perfectionist@5.8.0: - resolution: {integrity: sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==} - engines: {node: ^20.0.0 || >=22.0.0} - peerDependencies: - eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} - engines: {node: 20 || >=22} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - natural-orderby@5.0.0: - resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} - engines: {node: '>=18'} - - obug@2.1.1: - resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss@8.5.9: - resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - rimraf@6.1.3: - resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} - engines: {node: 20 || >=22} - hasBin: true - - rolldown@1.0.0-rc.15: - resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.1.1: - resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} - engines: {node: '>=18'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.58.2: - resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - vite@8.0.8: - resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.4: - resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.4 - '@vitest/browser-preview': 4.1.4 - '@vitest/browser-webdriverio': 4.1.4 - '@vitest/coverage-istanbul': 4.1.4 - '@vitest/coverage-v8': 4.1.4 - '@vitest/ui': 4.1.4 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - '@azure/functions-extensions-base@0.2.0': {} - - '@azure/functions@4.12.0': - dependencies: - '@azure/functions-extensions-base': 0.2.0 - cookie: 0.7.2 - - '@emnapi/core@1.9.2': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.9.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': - dependencies: - eslint: 9.39.4 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@oxc-project/types@0.124.0': {} - - '@rolldown/binding-android-arm64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.15': {} - - '@standard-schema/spec@1.1.0': {} - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@20.19.39': - dependencies: - undici-types: 6.21.0 - - '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.2 - eslint: 9.39.4 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.2 - debug: 4.4.3 - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) - '@typescript-eslint/types': 8.58.2 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.58.2': - dependencies: - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/visitor-keys': 8.58.2 - - '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.58.2': {} - - '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/visitor-keys': 8.58.2 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.58.2(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/types': 8.58.2 - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.58.2': - dependencies: - '@typescript-eslint/types': 8.58.2 - eslint-visitor-keys: 5.0.1 - - '@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)(vitest@4.1.4(@types/node@20.19.39)(vite@8.0.8(@types/node@20.19.39)))': - dependencies: - '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - typescript: 5.9.3 - vitest: 4.1.4(@types/node@20.19.39)(vite@8.0.8(@types/node@20.19.39)) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@4.1.4': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@20.19.39))': - dependencies: - '@vitest/spy': 4.1.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.8(@types/node@20.19.39) - - '@vitest/pretty-format@4.1.4': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.4': - dependencies: - '@vitest/utils': 4.1.4 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.4': - dependencies: - '@vitest/pretty-format': 4.1.4 - '@vitest/utils': 4.1.4 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.4': {} - - '@vitest/utils@4.1.4': - dependencies: - '@vitest/pretty-format': 4.1.4 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - assertion-error@2.0.1: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - callsites@3.1.0: {} - - chai@6.2.2: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concat-map@0.0.1: {} - - convert-source-map@2.0.0: {} - - cookie@0.7.2: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - detect-libc@2.1.2: {} - - es-module-lexer@2.0.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-config-prettier@10.1.8(eslint@9.39.4): - dependencies: - eslint: 9.39.4 - - eslint-plugin-perfectionist@5.8.0(eslint@9.39.4)(typescript@5.9.3): - dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - natural-orderby: 5.0.0 - transitivePeerDependencies: - - supports-color - - typescript - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - esutils@2.0.3: {} - - expect-type@1.3.0: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - fsevents@2.3.3: - optional: true - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - - globals@14.0.0: {} - - has-flag@4.0.0: {} - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - isexe@2.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - lru-cache@11.3.5: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minipass@7.1.3: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - natural-orderby@5.0.0: {} - - obug@2.1.1: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - package-json-from-dist@1.0.1: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.3.5 - minipass: 7.1.3 - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - postcss@8.5.9: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - punycode@2.3.1: {} - - resolve-from@4.0.0: {} - - rimraf@6.1.3: - dependencies: - glob: 13.0.6 - package-json-from-dist: 1.0.1 - - rolldown@1.0.0-rc.15: - dependencies: - '@oxc-project/types': 0.124.0 - '@rolldown/pluginutils': 1.0.0-rc.15 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-x64': 1.0.0-rc.15 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 - - semver@7.7.4: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@4.0.0: {} - - strip-json-comments@3.1.1: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - tinybench@2.9.0: {} - - tinyexec@1.1.1: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinyrainbow@3.1.0: {} - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - tslib@2.8.1: - optional: true - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.58.2(eslint@9.39.4)(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - vite@8.0.8(@types/node@20.19.39): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.9 - rolldown: 1.0.0-rc.15 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 20.19.39 - fsevents: 2.3.3 - - vitest@4.1.4(@types/node@20.19.39)(vite@8.0.8(@types/node@20.19.39)): - dependencies: - '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@20.19.39)) - '@vitest/pretty-format': 4.1.4 - '@vitest/runner': 4.1.4 - '@vitest/snapshot': 4.1.4 - '@vitest/spy': 4.1.4 - '@vitest/utils': 4.1.4 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.8(@types/node@20.19.39) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 20.19.39 - transitivePeerDependencies: - - msw - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - word-wrap@1.2.5: {} - - yocto-queue@0.1.0: {} - - zod@4.3.6: {} diff --git a/auth-function/src/__tests__/cors.test.ts b/auth-function/src/__tests__/cors.test.ts deleted file mode 100644 index bb7d126..0000000 --- a/auth-function/src/__tests__/cors.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { parseAllowedOrigins } from '../cors.js'; - -const DEV_ORIGIN = 'http://localhost:5173'; - -describe('parseAllowedOrigins', () => { - it('returns just the dev origin when input is undefined', () => { - expect(parseAllowedOrigins(undefined)).toEqual([DEV_ORIGIN]); - }); - - it('parses a single CSV origin', () => { - expect(parseAllowedOrigins('https://emergentsoftware.github.io')).toEqual([ - DEV_ORIGIN, - 'https://emergentsoftware.github.io', - ]); - }); - - it('parses multiple CSV origins and trims whitespace', () => { - expect( - parseAllowedOrigins('https://a.example.com, https://b.example.com'), - ).toEqual([DEV_ORIGIN, 'https://a.example.com', 'https://b.example.com']); - }); - - it('parses a JSON-array string', () => { - expect( - parseAllowedOrigins('["https://emergentsoftware.github.io"]'), - ).toEqual([DEV_ORIGIN, 'https://emergentsoftware.github.io']); - }); - - it('parses a JSON array with multiple origins', () => { - expect( - parseAllowedOrigins('["https://a.example.com","https://b.example.com"]'), - ).toEqual([DEV_ORIGIN, 'https://a.example.com', 'https://b.example.com']); - }); - - it('strips stray surrounding quotes on a CSV entry', () => { - expect(parseAllowedOrigins('"https://emergentsoftware.github.io"')).toEqual( - [DEV_ORIGIN, 'https://emergentsoftware.github.io'], - ); - }); - - it('falls back to CSV parsing when JSON is malformed', () => { - expect(parseAllowedOrigins('[https://a.example.com')).toEqual([ - DEV_ORIGIN, - '[https://a.example.com', - ]); - }); - - it('deduplicates the dev origin when provided explicitly', () => { - expect(parseAllowedOrigins(DEV_ORIGIN)).toEqual([DEV_ORIGIN]); - }); -}); diff --git a/auth-function/src/__tests__/exchange.test.ts b/auth-function/src/__tests__/exchange.test.ts deleted file mode 100644 index f3b90fc..0000000 --- a/auth-function/src/__tests__/exchange.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { HttpRequest, InvocationContext } from '@azure/functions'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { exchangeHandler } from '../exchange.js'; - -const ALLOWED_ORIGIN = 'https://emergentsoftware.github.io'; - -function makeContext(): { context: InvocationContext; logs: string[] } { - const logs: string[] = []; - const log = vi.fn((msg: string) => { - logs.push(msg); - }); - // The handler only uses context.log(), so a minimal stub is enough. - const context = { log } as unknown as InvocationContext; - return { context, logs }; -} - -function makeRequest(init: { - body?: unknown; - headers?: Record; - method?: string; -}): HttpRequest { - const headers = new Headers(init.headers); - const method = init.method ?? 'POST'; - const hasBody = init.body !== undefined && method !== 'GET' && method !== 'OPTIONS'; - const req = new Request('http://localhost/api/auth/exchange', { - body: hasBody ? JSON.stringify(init.body) : undefined, - headers, - method, - }); - return req as unknown as HttpRequest; -} - -describe('exchangeHandler', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - process.env.GITHUB_OAUTH_CLIENT_ID = 'test-client-id'; - process.env.GITHUB_OAUTH_CLIENT_SECRET = 'test-client-secret'; - process.env.CORS_ALLOWED_ORIGINS = ALLOWED_ORIGIN; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - delete process.env.GITHUB_OAUTH_CLIENT_ID; - delete process.env.GITHUB_OAUTH_CLIENT_SECRET; - delete process.env.CORS_ALLOWED_ORIGINS; - vi.restoreAllMocks(); - }); - - it('exchanges a valid code for an access token', async () => { - const upstream = { - access_token: 'gho_secret_token_xyz', - scope: 'read:org,repo', - token_type: 'bearer', - }; - const fetchMock = vi.fn(async () => - new Response(JSON.stringify(upstream), { - headers: { 'Content-Type': 'application/json' }, - status: 200, - }), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const { context, logs } = makeContext(); - const req = makeRequest({ - body: { code: 'abc123' }, - headers: { 'Content-Type': 'application/json', Origin: ALLOWED_ORIGIN }, - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(200); - expect(response.jsonBody).toEqual(upstream); - const headers = response.headers as Record; - expect(headers['Access-Control-Allow-Origin']).toBe(ALLOWED_ORIGIN); - expect(headers.Vary).toBe('Origin'); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [, init] = fetchMock.mock.calls[0]; - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toEqual({ - client_id: 'test-client-id', - client_secret: 'test-client-secret', - code: 'abc123', - }); - - for (const line of logs) { - expect(line).not.toContain('gho_secret_token_xyz'); - expect(line).not.toContain('test-client-secret'); - expect(line).not.toContain('abc123'); - } - }); - - it('maps GitHub OAuth error responses to a 400 error envelope', async () => { - globalThis.fetch = vi.fn(async () => - new Response( - JSON.stringify({ - error: 'bad_verification_code', - error_description: 'The code passed is incorrect or expired.', - error_uri: 'https://docs.github.com/', - }), - { - headers: { 'Content-Type': 'application/json' }, - status: 200, - }, - ), - ) as unknown as typeof fetch; - - const { context } = makeContext(); - const req = makeRequest({ - body: { code: 'expired' }, - headers: { 'Content-Type': 'application/json', Origin: ALLOWED_ORIGIN }, - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(400); - expect(response.jsonBody).toEqual({ - error: 'bad_verification_code', - message: 'The code passed is incorrect or expired.', - }); - }); - - it('rejects a missing/empty code with a 400 validation error', async () => { - const { context } = makeContext(); - const req = makeRequest({ - body: { code: '' }, - headers: { 'Content-Type': 'application/json', Origin: ALLOWED_ORIGIN }, - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(400); - const body = response.jsonBody as { error: string; message: string }; - expect(body.error).toBe('invalid_request'); - expect(body.message).toContain('code'); - }); - - it('returns 500 when GitHub OAuth app settings are missing', async () => { - delete process.env.GITHUB_OAUTH_CLIENT_ID; - delete process.env.GITHUB_OAUTH_CLIENT_SECRET; - - const { context } = makeContext(); - const req = makeRequest({ - body: { code: 'abc123' }, - headers: { 'Content-Type': 'application/json', Origin: ALLOWED_ORIGIN }, - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(500); - expect(response.jsonBody).toEqual({ - error: 'server_misconfigured', - message: 'OAuth application settings are not configured.', - }); - }); - - it('responds to CORS preflight OPTIONS with 204 and allowed-origin headers', async () => { - const { context } = makeContext(); - const req = makeRequest({ - headers: { - 'Access-Control-Request-Method': 'POST', - Origin: ALLOWED_ORIGIN, - }, - method: 'OPTIONS', - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(204); - const headers = response.headers as Record; - expect(headers['Access-Control-Allow-Origin']).toBe(ALLOWED_ORIGIN); - expect(headers['Access-Control-Allow-Methods']).toContain('POST'); - expect(headers['Access-Control-Allow-Methods']).toContain('OPTIONS'); - expect(headers['Access-Control-Allow-Headers']).toContain('Content-Type'); - expect(headers.Vary).toBe('Origin'); - }); - - it('rejects preflight from an unknown origin with 403 and no CORS headers', async () => { - const { context } = makeContext(); - const req = makeRequest({ - headers: { Origin: 'https://evil.example.com' }, - method: 'OPTIONS', - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(403); - const headers = response.headers as Record; - expect(headers['Access-Control-Allow-Origin']).toBeUndefined(); - }); - - it('rejects POSTs from an unknown origin before calling GitHub', async () => { - const fetchMock = vi.fn(); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const { context } = makeContext(); - const req = makeRequest({ - body: { code: 'abc123' }, - headers: { - 'Content-Type': 'application/json', - Origin: 'https://evil.example.com', - }, - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(403); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('maps upstream network failures to a 502 error envelope', async () => { - globalThis.fetch = vi.fn(async () => { - throw new TypeError('network unreachable'); - }) as unknown as typeof fetch; - - const { context, logs } = makeContext(); - const req = makeRequest({ - body: { code: 'abc123' }, - headers: { 'Content-Type': 'application/json', Origin: ALLOWED_ORIGIN }, - }); - - const response = await exchangeHandler(req, context); - - expect(response.status).toBe(502); - expect((response.jsonBody as { error: string }).error).toBe( - 'upstream_unavailable', - ); - for (const line of logs) { - expect(line).not.toContain('abc123'); - } - }); - - it('scrubs sensitive fields from error log output', async () => { - globalThis.fetch = vi.fn(async () => - new Response( - JSON.stringify({ - access_token: 'leaky_token', - error: 'server_error', - }), - { status: 500 }, - ), - ) as unknown as typeof fetch; - - const { context, logs } = makeContext(); - const req = makeRequest({ - body: { code: 'abc123' }, - headers: { 'Content-Type': 'application/json', Origin: ALLOWED_ORIGIN }, - }); - - await exchangeHandler(req, context); - - for (const line of logs) { - expect(line).not.toContain('leaky_token'); - expect(line).not.toContain('abc123'); - expect(line).not.toContain('test-client-secret'); - } - }); -}); diff --git a/auth-function/src/__tests__/health.test.ts b/auth-function/src/__tests__/health.test.ts deleted file mode 100644 index bfff45b..0000000 --- a/auth-function/src/__tests__/health.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { HttpRequest } from '@azure/functions'; - -import { describe, expect, it } from 'vitest'; - -import { healthHandler } from '../health.js'; - -function makeRequest(): HttpRequest { - const req = new Request('http://localhost/api/health', { method: 'GET' }); - return req as unknown as HttpRequest; -} - -describe('healthHandler', () => { - it('returns 200 with status ok and a version string', async () => { - const response = await healthHandler(makeRequest(), {} as never); - - expect(response.status).toBe(200); - const body = response.jsonBody as { status: string; version: string }; - expect(body.status).toBe('ok'); - expect(typeof body.version).toBe('string'); - expect(body.version.length).toBeGreaterThan(0); - }); - - it('marks the response as non-cacheable JSON', async () => { - const response = await healthHandler(makeRequest(), {} as never); - const headers = response.headers as Record; - - expect(headers['Cache-Control']).toBe('no-store'); - expect(headers['Content-Type']).toBe('application/json'); - }); -}); diff --git a/auth-function/src/cors.ts b/auth-function/src/cors.ts deleted file mode 100644 index 7aa6e1c..0000000 --- a/auth-function/src/cors.ts +++ /dev/null @@ -1,61 +0,0 @@ -const DEV_ORIGIN = 'http://localhost:5173'; - -export function isOriginAllowed( - requestOrigin: null | string | undefined, - allowedOrigins: string[], -): boolean { - return !!requestOrigin && allowedOrigins.includes(requestOrigin); -} - -export function parseAllowedOrigins(configured: string | undefined): string[] { - const set = new Set([DEV_ORIGIN]); - if (configured) { - for (const entry of splitConfigured(configured)) { - const cleaned = stripQuotes(entry.trim()); - if (cleaned) set.add(cleaned); - } - } - return Array.from(set); -} - -export function resolveCorsHeaders( - requestOrigin: null | string | undefined, - allowedOrigins: string[], -): Record { - const headers: Record = { - Vary: 'Origin', - }; - if (requestOrigin && allowedOrigins.includes(requestOrigin)) { - headers['Access-Control-Allow-Origin'] = requestOrigin; - headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'; - headers['Access-Control-Allow-Headers'] = 'Content-Type'; - headers['Access-Control-Max-Age'] = '600'; - } - return headers; -} - -function splitConfigured(configured: string): string[] { - const trimmed = configured.trim(); - if (trimmed.startsWith('[') && trimmed.endsWith(']')) { - try { - const parsed: unknown = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return parsed.map((v) => (typeof v === 'string' ? v : String(v))); - } - } catch { - // Fall through to CSV parsing on malformed JSON. - } - } - return trimmed.split(','); -} - -function stripQuotes(value: string): string { - if (value.length >= 2) { - const first = value[0]; - const last = value[value.length - 1]; - if ((first === '"' || first === "'") && first === last) { - return value.slice(1, -1); - } - } - return value; -} diff --git a/auth-function/src/exchange.ts b/auth-function/src/exchange.ts deleted file mode 100644 index 83d9add..0000000 --- a/auth-function/src/exchange.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { - HttpHandler, - HttpRequest, - HttpResponseInit, - InvocationContext, -} from '@azure/functions'; - -import { z } from 'zod'; - -import { - isOriginAllowed, - parseAllowedOrigins, - resolveCorsHeaders, -} from './cors.js'; -import { makeLogger, scrub } from './logging.js'; - -const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; - -const BodySchema = z.object({ - code: z.string().trim().min(1, 'code must be a non-empty string'), -}); - -interface ErrorEnvelope { - error: string; - message: string; -} - -function errorResponse( - status: number, - error: string, - message: string, - corsHeaders: Record, -): HttpResponseInit { - const envelope: ErrorEnvelope = { error, message }; - return jsonResponse(status, envelope, corsHeaders); -} - -function jsonResponse( - status: number, - body: unknown, - corsHeaders: Record, -): HttpResponseInit { - return { - headers: { - ...corsHeaders, - 'Cache-Control': 'no-store', - 'Content-Type': 'application/json', - }, - jsonBody: body, - status, - }; -} - -export const exchangeHandler: HttpHandler = async ( - request: HttpRequest, - context: InvocationContext, -): Promise => { - const log = makeLogger((msg) => context.log(msg)); - const origin = request.headers.get('origin'); - const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS); - const corsHeaders = resolveCorsHeaders(origin, allowedOrigins); - - if (request.method === 'OPTIONS') { - if (!isOriginAllowed(origin, allowedOrigins)) { - log.info({ - event: 'cors_preflight_rejected', - method: 'OPTIONS', - origin: origin ?? null, - }); - return { headers: { Vary: 'Origin' }, status: 403 }; - } - log.info({ event: 'cors_preflight', origin }); - return { headers: corsHeaders, status: 204 }; - } - - if (!isOriginAllowed(origin, allowedOrigins)) { - log.info({ - allowedOrigins, - configuredRaw: process.env.CORS_ALLOWED_ORIGINS ?? null, - event: 'origin_rejected', - method: request.method, - origin: origin ?? null, - originBytes: origin ? [...origin].map((c) => c.charCodeAt(0)) : null, - }); - return errorResponse( - 403, - 'origin_not_allowed', - 'Request origin is not permitted.', - { Vary: 'Origin' }, - ); - } - - if (request.method !== 'POST') { - return errorResponse( - 405, - 'method_not_allowed', - `Method ${request.method} is not supported.`, - { ...corsHeaders, Allow: 'POST, OPTIONS' }, - ); - } - - const clientId = process.env.GITHUB_OAUTH_CLIENT_ID; - const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET; - if (!clientId || !clientSecret) { - log.error({ - event: 'missing_app_settings', - has_client_id: !!clientId, - has_client_secret: !!clientSecret, - }); - return errorResponse( - 500, - 'server_misconfigured', - 'OAuth application settings are not configured.', - corsHeaders, - ); - } - - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return errorResponse( - 400, - 'invalid_json', - 'Request body must be valid JSON.', - corsHeaders, - ); - } - - const parsed = BodySchema.safeParse(rawBody); - if (!parsed.success) { - log.info({ - event: 'validation_error', - issues: parsed.error.issues.map((i) => ({ - code: i.code, - message: i.message, - path: i.path, - })), - }); - return errorResponse( - 400, - 'invalid_request', - parsed.error.issues[0]?.message ?? 'Request body failed validation.', - corsHeaders, - ); - } - - const { code } = parsed.data; - - let upstreamResponse: Response; - try { - upstreamResponse = await fetch(GITHUB_TOKEN_URL, { - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - code, - }), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': 'atk-auth-function', - }, - method: 'POST', - }); - } catch (err) { - log.error({ - event: 'upstream_network_error', - message: err instanceof Error ? err.message : String(err), - }); - return errorResponse( - 502, - 'upstream_unavailable', - 'Failed to reach GitHub to exchange the code.', - corsHeaders, - ); - } - - let upstreamBody: unknown; - try { - upstreamBody = await upstreamResponse.json(); - } catch { - log.error({ - event: 'upstream_invalid_json', - status: upstreamResponse.status, - }); - return errorResponse( - 502, - 'upstream_invalid_response', - 'GitHub returned a response that could not be parsed as JSON.', - corsHeaders, - ); - } - - if (!upstreamResponse.ok) { - log.error({ - body: scrub(upstreamBody), - event: 'upstream_http_error', - status: upstreamResponse.status, - }); - return errorResponse( - 502, - 'upstream_error', - `GitHub returned status ${upstreamResponse.status}.`, - corsHeaders, - ); - } - - if ( - upstreamBody && - typeof upstreamBody === 'object' && - 'error' in (upstreamBody as Record) - ) { - const body = upstreamBody as Record; - const ghError = - typeof body.error === 'string' ? body.error : 'github_error'; - const ghDescription = - typeof body.error_description === 'string' - ? body.error_description - : 'GitHub rejected the authorization code.'; - log.info({ - event: 'github_oauth_error', - github_error: ghError, - }); - return errorResponse(400, ghError, ghDescription, corsHeaders); - } - - log.info({ event: 'exchange_success' }); - return jsonResponse(200, upstreamBody, corsHeaders); -}; diff --git a/auth-function/src/health.ts b/auth-function/src/health.ts deleted file mode 100644 index fa0c9b1..0000000 --- a/auth-function/src/health.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { - HttpHandler, - HttpRequest, - HttpResponseInit, -} from '@azure/functions'; - -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const packageJsonPath = join( - dirname(fileURLToPath(import.meta.url)), - '..', - '..', - 'package.json', -); - -const version = (() => { - try { - const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { - version?: string; - }; - return pkg.version ?? 'unknown'; - } catch { - return 'unknown'; - } -})(); - -export const healthHandler: HttpHandler = async ( - _request: HttpRequest, -): Promise => { - return { - headers: { - 'Cache-Control': 'no-store', - 'Content-Type': 'application/json', - }, - jsonBody: { status: 'ok', version }, - status: 200, - }; -}; diff --git a/auth-function/src/index.ts b/auth-function/src/index.ts deleted file mode 100644 index afa2197..0000000 --- a/auth-function/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { app } from '@azure/functions'; - -import { exchangeHandler } from './exchange.js'; -import { healthHandler } from './health.js'; - -app.http('exchange', { - authLevel: 'anonymous', - handler: exchangeHandler, - methods: ['POST', 'OPTIONS'], - route: 'auth/exchange', -}); - -app.http('health', { - authLevel: 'anonymous', - handler: healthHandler, - methods: ['GET'], - route: 'health', -}); diff --git a/auth-function/src/logging.ts b/auth-function/src/logging.ts deleted file mode 100644 index 576b0a5..0000000 --- a/auth-function/src/logging.ts +++ /dev/null @@ -1,42 +0,0 @@ -const SENSITIVE_KEYS = new Set([ - 'access_token', - 'authorization', - 'client_secret', - 'code', - 'refresh_token', -]); - -const REDACTED = '[REDACTED]'; - -export interface Logger { - error: (payload: Record) => void; - info: (payload: Record) => void; -} - -export function makeLogger(log: (msg: string) => void): Logger { - const emit = (level: string, payload: Record) => { - const line = { - level, - timestamp: new Date().toISOString(), - ...(scrub(payload) as Record), - }; - log(JSON.stringify(line)); - }; - return { - error: (payload) => emit('error', payload), - info: (payload) => emit('info', payload), - }; -} - -export function scrub(value: unknown): unknown { - if (value === null || value === undefined) return value; - if (Array.isArray(value)) return value.map(scrub); - if (typeof value === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? REDACTED : scrub(v); - } - return out; - } - return value; -} diff --git a/auth-function/tsconfig.json b/auth-function/tsconfig.json deleted file mode 100644 index a9ee3cb..0000000 --- a/auth-function/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": ".", - "resolveJsonModule": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "declaration": false, - "sourceMap": true, - "types": ["node"] - }, - "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "src/**/*.test.ts", "src/**/__tests__/**"] -} diff --git a/auth-function/vitest.config.ts b/auth-function/vitest.config.ts deleted file mode 100644 index bfcd45f..0000000 --- a/auth-function/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'node', - globals: false, - include: ['src/**/*.test.ts', 'src/**/__tests__/**/*.test.ts'], - }, -}); diff --git a/docs/OAUTH_APP_SETUP.md b/docs/OAUTH_APP_SETUP.md index fe40a27..5f25798 100644 --- a/docs/OAUTH_APP_SETUP.md +++ b/docs/OAUTH_APP_SETUP.md @@ -1,8 +1,8 @@ # GitHub OAuth App Setup — Run Sheet -This run sheet walks through registering the **dev** and **production** GitHub OAuth Apps that back ATK Web's sign-in flow, and wiring their credentials into the auth function and the SPA. +This run sheet walks through registering the **dev** and **production** GitHub OAuth Apps that back ATK Web's sign-in flow, and wiring their credentials into the ATK API and the SPA. -Two OAuth Apps are required — one for local development, one for production — so that local experimentation never hits the production callback URL and so that rotating one secret never disrupts the other environment. +Two OAuth Apps are required — one for local development, one for production — so that local experimentation never hits the production callback URL and so that rotating one secret never disrupts the other environment. Each OAuth App is paired with one ATK API environment: **dev app ↔ dev API** (`func-atk-dev`), **prod app ↔ prod API** (`func-atk-prod`). --- @@ -11,11 +11,11 @@ Two OAuth Apps are required — one for local development, one for production 1. [Prerequisites](#1-prerequisites) 2. [Concepts](#2-concepts) 3. [Register the DEV OAuth App](#3-register-the-dev-oauth-app) -4. [Wire DEV credentials into the auth function](#4-wire-dev-credentials-into-the-auth-function) +4. [Wire DEV credentials into the dev ATK API](#4-wire-dev-credentials-into-the-dev-atk-api) 5. [Wire DEV credentials into the SPA](#5-wire-dev-credentials-into-the-spa) 6. [Verify the DEV end-to-end handshake](#6-verify-the-dev-end-to-end-handshake) 7. [Register the PROD OAuth App](#7-register-the-prod-oauth-app) -8. [Wire PROD credentials into the Azure Function App](#8-wire-prod-credentials-into-the-azure-function-app) +8. [Wire PROD credentials into the prod ATK API](#8-wire-prod-credentials-into-the-prod-atk-api) 9. [Wire PROD credentials into the SPA build](#9-wire-prod-credentials-into-the-spa-build) 10. [Rotating a client secret](#10-rotating-a-client-secret) 11. [Revoking / deleting an OAuth App](#11-revoking--deleting-an-oauth-app) @@ -27,11 +27,11 @@ Two OAuth Apps are required — one for local development, one for production Before starting, confirm you have: -- [ ] **Owner** permission on the `EmergentSoftware` GitHub organization (required to register an org-owned OAuth App and to add production secrets to the `agentic-toolkit-web` repo). +- [ ] **Owner** permission on the `EmergentSoftware` GitHub organization (required to register an org-owned OAuth App and to add production variables to the `agentic-toolkit-web` repo). - [ ] **Contributor** access to the `EmergentSoftware/agentic-toolkit-web` repository. -- [ ] **Contributor** (or `Application Administrator`) access to the Azure subscription that hosts the production Function App, with permission to edit Function App **Configuration → Application settings**. +- [ ] Access to the `Emergent.AgenticToolkit` monorepo in Azure DevOps and permission to set Key Vault secrets and Terraform variables for the ATK API (see its `infra/README.md`). - [ ] A password manager or other secure secret store (for stashing each OAuth App's `client_secret`, which is shown **exactly once** at creation). -- [ ] Locally: Node.js `>= 20.x` and `pnpm` `10.5.2`, plus [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (`func --version` should report `4.x`). +- [ ] Locally: Node.js `>= 24.x` and `pnpm` `10.5.2`. > **Tip:** Register both OAuth Apps under the **EmergentSoftware org**, not under a personal account. Personal-account OAuth Apps disappear with the account and can't be transferred cleanly. @@ -42,11 +42,12 @@ Before starting, confirm you have: | Term | Meaning | |---|---| | **OAuth App** | A GitHub registration that issues `client_id` / `client_secret` pairs and a callback URL. Users authorize the app; GitHub then redirects to the callback URL with a short-lived `code`. | -| **`client_id`** | Public identifier for the OAuth App. Embedded in the SPA at build time via `VITE_GITHUB_OAUTH_CLIENT_ID`. Not a secret. | -| **`client_secret`** | Secret identifier that proves the exchange request is legitimate. **Must never ship to the browser.** Held only by the auth function. | +| **`client_id`** | Public identifier for the OAuth App. Embedded in the SPA at build time via `VITE_GITHUB_OAUTH_CLIENT_ID`, and configured on the matching ATK API environment. Not a secret. | +| **`client_secret`** | Secret identifier that proves the exchange request is legitimate. **Must never ship to the browser.** Held only by the ATK API (Key Vault). | | **Authorization callback URL** | Where GitHub redirects after the user consents. Must exactly match the SPA's runtime origin + `/#/auth/callback` (the hash route is because ATK Web uses `HashRouter`). | -| **`code`** | A one-time, short-lived token the SPA receives in the callback URL. The SPA `POST`s it to the auth function, which exchanges it for an `access_token` by calling GitHub with the `client_secret`. | -| **`access_token`** | A GitHub user access token scoped to `read:org` and `repo`. Stored in `sessionStorage` in the browser. Never persisted server-side. | +| **`code`** | A one-time, short-lived token the SPA receives in the callback URL. The SPA `POST`s it to the ATK API (`/auth/github/exchange`), which exchanges it for an `access_token` by calling GitHub with the `client_secret`. | +| **`access_token`** | A GitHub user access token scoped to `read:org` and `repo`. Stored in `sessionStorage` in the browser and sent to the ATK API as `Authorization: Bearer …`. Never persisted server-side. | +| **`VITE_ATK_API_URL`** | Base URL of the ATK API the SPA talks to: the dev API locally, the prod API on GitHub Pages. | --- @@ -73,38 +74,21 @@ Before starting, confirm you have: --- -## 4. Wire DEV credentials into the auth function +## 4. Wire DEV credentials into the dev ATK API -The auth function reads credentials from `auth-function/local.settings.json`, which is **git-ignored**. A committed template (`auth-function/local.settings.json.template`) documents the required keys. +The dev API (`https://func-atk-dev.azurewebsites.net`) holds the dev OAuth App's credentials. In the `Emergent.AgenticToolkit` monorepo: -1. From the repo root: +1. Set `github_oauth_client_id` in `infra/envs/dev/main.tf` to the DEV client id and apply (or set the Function App setting directly if Terraform is not being run). +2. Set the Key Vault secret `github-oauth-client-secret` in the **dev** Key Vault to the DEV client secret. +3. Confirm `cors_allowed_origins` on the dev Function App includes `http://localhost:5173`. - ```bash - cp auth-function/local.settings.json.template auth-function/local.settings.json - ``` - -2. Open `auth-function/local.settings.json` and fill in the values you recorded in step 3: - - ```jsonc - { - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "", - "FUNCTIONS_WORKER_RUNTIME": "node", - "GITHUB_OAUTH_CLIENT_ID": "", - "GITHUB_OAUTH_CLIENT_SECRET": "", - "CORS_ALLOWED_ORIGINS": "http://localhost:5173" - } - } - ``` - -3. Verify `auth-function/local.settings.json` is listed in `auth-function/.gitignore` (or the root `.gitignore`) and that `git status` does **not** show it as a tracked or staged file. If it does, stop and fix the ignore rule before continuing. +See the monorepo's `infra/README.md` for the exact commands. Nothing in this repo needs the secret. --- ## 5. Wire DEV credentials into the SPA -The SPA needs the DEV `client_id` (public) and the auth-function URL (public) at dev server startup. +The SPA needs the DEV `client_id` (public) and the dev API URL (public) at dev server startup. 1. From the repo root: @@ -116,35 +100,20 @@ The SPA needs the DEV `client_id` (public) and the auth-function URL (public) at ```bash VITE_GITHUB_OAUTH_CLIENT_ID= - VITE_AUTH_FUNCTION_URL=http://localhost:7071 + VITE_ATK_API_URL=https://func-atk-dev.azurewebsites.net ``` - `7071` is the default port Azure Functions Core Tools uses for local HTTP triggers. + To run against a local `func start` of the API instead, use `http://localhost:7071`. 3. Confirm `.env.local` is git-ignored (Vite projects ignore `.env.local` by default; verify with `git status`). -> `VITE_AUTH_FUNCTION_URL` is the base URL. The SPA appends `/api/auth/exchange` itself. Do **not** include a trailing slash. +> `VITE_ATK_API_URL` is the base URL with **no `/api` prefix and no trailing slash**. The SPA appends `/auth/github/exchange`, `/me`, `/registry`, etc. itself. --- ## 6. Verify the DEV end-to-end handshake -1. **Terminal A** — start the auth function: - - ```bash - cd auth-function - pnpm install - pnpm start # invokes `func start` under the hood - ``` - - Expect output containing: - - ``` - Functions: - exchange: [POST] http://localhost:7071/api/auth/exchange - ``` - -2. **Terminal B** — start the SPA: +1. Start the SPA: ```bash pnpm install @@ -153,12 +122,13 @@ The SPA needs the DEV `client_id` (public) and the auth-function URL (public) at Expect Vite to print `Local: http://localhost:5173/`. -3. In the browser, open `http://localhost:5173`, click **Sign in with GitHub**, and complete consent. -4. On the callback, open the browser devtools **Network** tab and confirm: - - A `POST http://localhost:7071/api/auth/exchange` request fires. +2. In the browser, open `http://localhost:5173`, click **Sign in with GitHub**, and complete consent. +3. On the callback, open the browser devtools **Network** tab and confirm: + - A `POST https://func-atk-dev.azurewebsites.net/auth/github/exchange` request fires. - The response is `200 OK` with a JSON body containing `access_token`. - The response **does not** include a `Set-Cookie` header or any reflection of `client_secret`. -5. Confirm the SPA advances past the sign-in screen. If it lands on the org-membership blocking screen, that is expected for any account that isn't a member of `EmergentSoftware`. + - A `GET https://func-atk-dev.azurewebsites.net/me` request follows with an `Authorization: Bearer …` header and returns `200` with your login. +4. Confirm the SPA advances past the sign-in screen. If it lands on the org-membership blocking screen, that is expected for any account that isn't a member of `EmergentSoftware` (the API answered `403`). > **Stop here if any step fails.** Jump to [Troubleshooting](#12-troubleshooting) before moving to production. @@ -186,50 +156,46 @@ Only do this once the dev app is fully working and the production Pages URL is k --- -## 8. Wire PROD credentials into the Azure Function App - -Never commit the production `client_secret`. It lives only in Azure. +## 8. Wire PROD credentials into the prod ATK API -1. Sign in to **`https://portal.azure.com`**. -2. Navigate to the production Function App (name TBD — filled in during Phase 11 deployment setup). -3. Left nav → **Settings → Environment variables** (formerly **Configuration → Application settings**). -4. Add or update these **Application settings**: - - | Name | Value | - |---|---| - | `GITHUB_OAUTH_CLIENT_ID` | `` | - | `GITHUB_OAUTH_CLIENT_SECRET` | `` | - | `CORS_ALLOWED_ORIGINS` | `https://emergentsoftware.github.io` | - | `FUNCTIONS_WORKER_RUNTIME` | `node` | - | `WEBSITE_NODE_DEFAULT_VERSION` | `~20` | +Never commit the production `client_secret`. It lives only in the prod Key Vault. -5. Click **Apply** / **Save**, then **Continue** when Azure warns the app will restart. -6. Verify: +1. In the monorepo, set `github_oauth_client_id` in `infra/envs/prod/main.tf` to the PROD client id and apply. +2. Set the Key Vault secret `github-oauth-client-secret` in the **prod** Key Vault to the PROD client secret. +3. Confirm `cors_allowed_origins` on the prod Function App includes `https://emergentsoftware.github.io`. +4. Verify: ```bash - curl -i -X OPTIONS https:///api/auth/exchange \ + curl -i -X OPTIONS https://func-atk-prod.azurewebsites.net/auth/github/exchange \ -H "Origin: https://emergentsoftware.github.io" \ -H "Access-Control-Request-Method: POST" ``` The response should include `Access-Control-Allow-Origin: https://emergentsoftware.github.io`. **It must not** echo `*` and must not echo any other origin. -7. Confirm that no Application Insights log entry from the function contains the literal value of `GITHUB_OAUTH_CLIENT_SECRET`, any `code` value, or any `access_token` value. If any of these leak, stop and file a rotation ticket before continuing (see [§10](#10-rotating-a-client-secret)). +5. Confirm that no Application Insights log entry from the API contains the literal value of the client secret, any `code` value, or any `access_token` value. If any of these leak, stop and file a rotation ticket before continuing (see [§10](#10-rotating-a-client-secret)). --- ## 9. Wire PROD credentials into the SPA build -The production GitHub Actions deploy workflow (Phase 11) injects these at build time. +The GitHub Pages deploy workflow injects these at build time. -1. Navigate to **`https://github.com/EmergentSoftware/agentic-toolkit-web/settings/secrets/actions`**. -2. Add the following **repository secrets** (Secrets, not Variables — they're consumed by the build and then compiled into the bundle; do not mistake their visibility in logs for safety): +1. Navigate to **`https://github.com/EmergentSoftware/agentic-toolkit-web/settings/variables/actions`**. +2. Add the following **repository variables** (Variables, not Secrets — both values are public and end up in the compiled bundle): - | Secret name | Value | + | Variable name | Value | |---|---| - | `PROD_VITE_GITHUB_OAUTH_CLIENT_ID` | `` | - | `PROD_VITE_AUTH_FUNCTION_URL` | `https://` | + | `VITE_GITHUB_OAUTH_CLIENT_ID` | `` | + | `VITE_ATK_API_URL` | `https://func-atk-prod.azurewebsites.net` | -3. The deploy workflow maps these to `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_AUTH_FUNCTION_URL` before invoking `pnpm build`. No other SPA-side action is needed. + Or from a terminal with the `gh` CLI: + + ```bash + gh variable set VITE_GITHUB_OAUTH_CLIENT_ID --body + gh variable set VITE_ATK_API_URL --body https://func-atk-prod.azurewebsites.net + ``` + +3. `deploy-pages.yml` passes these straight to `pnpm build`. No other SPA-side action is needed. > `VITE_GITHUB_OAUTH_CLIENT_ID` is public — it ends up in the compiled JavaScript bundle. GitHub's OAuth model assumes the `client_id` is visible to the browser; security comes from the `client_secret` staying server-side. @@ -243,19 +209,19 @@ Rotate on a schedule (every 90 days is a reasonable cadence) and immediately if 1. GitHub → OAuth App **ATK Web (dev)** → **Generate a new client secret**. 2. Copy the new value into your password manager. -3. Update `auth-function/local.settings.json` locally. -4. Restart `func start`. +3. Update `github-oauth-client-secret` in the dev Key Vault; restart the dev Function App if needed. +4. Sign in locally to confirm the exchange still works. 5. Return to the old secret on the GitHub page and click **Revoke** on the old secret row. **Rotate the PROD secret:** 1. GitHub → OAuth App **ATK Web** → **Generate a new client secret**. (GitHub allows two secrets to coexist during rotation.) 2. Copy the new value into the password manager. -3. Azure Portal → Function App → Environment variables → update `GITHUB_OAUTH_CLIENT_SECRET` to the new value → **Apply** → wait for restart. +3. Update `github-oauth-client-secret` in the prod Key Vault; restart the prod Function App if needed. 4. Verify sign-in still works end-to-end on the production URL. 5. Return to the GitHub OAuth App page and click **Revoke** on the old secret row. -**Do not** revoke the old secret before the new one is live in Azure — doing so causes a brief outage where every sign-in fails. +**Do not** revoke the old secret before the new one is live in the API — doing so causes a brief outage where every sign-in fails. --- @@ -265,7 +231,7 @@ If an OAuth App must be retired (e.g., the callback URL permanently changed and 1. Communicate the change ahead of time — all active user sessions will eventually need to re-authorize against the new app. 2. GitHub → OAuth App → **Delete application** at the bottom of the settings page. Confirm by typing the app name. -3. Clean up any `client_id` / `client_secret` references in Azure Function settings, local `local.settings.json`, and GitHub Actions secrets. +3. Clean up any `client_id` / `client_secret` references in the API's Terraform variables and Key Vault, local `.env.local`, and GitHub Actions variables. 4. Remove any password-manager entries that reference the retired app. --- @@ -281,32 +247,33 @@ The callback URL registered on the OAuth App does not exactly match what the SPA - Host matches (`localhost:5173` with the explicit port; no trailing slash). - GitHub trims trailing whitespace poorly — re-enter the value if copy-paste introduced a stray space. -### `401` from the auth function on `POST /api/auth/exchange` +### `Auth exchange failed (HTTP 400): …` on `POST /auth/github/exchange` -Usually a bad or expired `code`. Codes expire after ~10 minutes and are single-use. Start the flow over from the sign-in button. If the error persists: +Usually a bad or expired `code` (`bad_verification_code`). Codes expire after ~10 minutes and are single-use. Start the flow over from the sign-in button. If the error persists: -- Confirm `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` in the function's environment come from the **same** OAuth App (dev matches dev, prod matches prod — not mixed). +- Confirm the SPA's `VITE_GITHUB_OAUTH_CLIENT_ID` and the API environment's client id come from the **same** OAuth App (dev SPA ↔ dev API, prod SPA ↔ prod API — not mixed). - Confirm the SPA is sending the `code` it just received, not a cached value. -### `CORS` preflight fails in the browser +### `Auth exchange failed (HTTP 500)` -- Confirm `CORS_ALLOWED_ORIGINS` exactly matches the browser's `Origin` header, including scheme and port — no wildcards, no trailing slash. -- Confirm the function is running Node v4 programming model with the `Access-Control-*` headers set on the `OPTIONS` branch. -- If behind a corporate proxy that rewrites `Origin`, test from a different network to rule out network-layer interference. +The API environment is missing its OAuth configuration (`server_misconfigured`). Check the Terraform variable and the Key Vault secret for that environment. -### GitHub returns `bad_verification_code` +### `CORS` preflight fails in the browser -The `code` has already been exchanged once or has expired. Start the flow over. Codes are single-use. +- Confirm the API's `cors_allowed_origins` exactly matches the browser's `Origin` header, including scheme and port — no wildcards, no trailing slash. +- If behind a corporate proxy that rewrites `Origin`, test from a different network to rule out network-layer interference. ### Sign-in succeeds but the app drops to the non-member blocking screen -Expected if the signed-in GitHub account is not a member of the `EmergentSoftware` org. An org owner must add them (GitHub → `EmergentSoftware` → People → Invite member) before they can use the app. This is not an OAuth App configuration problem. +Expected if the signed-in GitHub account is not a member of the `EmergentSoftware` org (`GET /me` → `403 not_org_member`). An org owner must add them (GitHub → `EmergentSoftware` → People → Invite member) before they can use the app. + +If the account **is** a member, the API answered `403 org_membership_unverifiable`: either SAML SSO has not been authorized for the token, or the OAuth App is not approved for the org (`https://github.com/orgs/EmergentSoftware/policies/applications`). The browser console logs which hint applies. ### `client_secret` accidentally committed to a public location Treat as compromised. Immediately: -1. Rotate per [§10](#10-rotating-a-client-secret) — generate the new secret and roll it out to the function before revoking the old one. +1. Rotate per [§10](#10-rotating-a-client-secret) — generate the new secret and roll it out to the API before revoking the old one. 2. Revoke the old secret the moment the new one is live. 3. Scrub the secret from git history if it was committed to any repo we control (`git filter-repo` or GitHub's secret scanning remediation). 4. Audit recent sign-in activity in GitHub's audit log for anything suspicious during the exposure window. diff --git a/docs/deployment.md b/docs/deployment.md index 40bf71d..20e5190 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,37 +1,42 @@ # Production Deployment Runbook -This document describes how to deploy, configure, rotate secrets for, and roll back the two components of ATK Web: +This document describes how to deploy, configure, and roll back ATK Web. - **SPA** — a Vite-built static site published to GitHub Pages by `.github/workflows/deploy-pages.yml`. -- **Auth function** — an Azure Functions v4 Node app (`auth-function/`) that brokers the GitHub OAuth code-for-token exchange, deployed by `.github/workflows/deploy-auth-function.yml`. +- **ATK API** — the shared .NET API (`func-atk-prod` / `func-atk-dev` Azure Function Apps) that the SPA talks to for the OAuth code exchange, registry reads, downloads, and publishing. The API is built and deployed from the `Emergent.AgenticToolkit` monorepo in Azure DevOps; **nothing in this repo deploys it**. -Both components deploy automatically on pushes to `main`. There is only one environment: **production**. There is no staging or deployment slot. +The SPA deploys automatically on pushes to `main`. There is only one SPA environment: **production**, which talks to the **prod** API. Local development (`pnpm dev`) talks to the **dev** API. Related docs: - `docs/OAUTH_APP_SETUP.md` — full step-by-step OAuth App registration playbook (dev and prod). - `docs/PHASED_IMPLEMENTATION.md` — project-wide implementation phases. +- `docs/Direction.md` and `docs/design/ClientContract.md` in the `Emergent.AgenticToolkit` monorepo — the API's contract and roadmap. --- ## 1. Architecture at a glance ``` - GitHub Pages (SPA) Azure Functions (auth-function) - https://emergentsoftware https://.azurewebsites.net - .github.io/agentic-toolkit-web /api/auth/exchange (POST, CORS-gated) - /api/health (GET, unauthenticated) + GitHub Pages (SPA) ATK API (Azure Functions, .NET) + https://emergentsoftware https://func-atk-prod.azurewebsites.net (prod, used by Pages) + .github.io/agentic-toolkit-web https://func-atk-dev.azurewebsites.net (dev, used by pnpm dev) + POST /auth/github/exchange (OAuth code → token; CORS-gated) + GET /me (identity + org-membership gate) + GET /registry, /assets/…, /bundles/… (reads, downloads) + POST /publish, /publish/plan (open a registry PR) ``` - The SPA runs entirely in the browser using `HashRouter`; no server-side routing is required. -- The SPA calls `POST /api/auth/exchange` to swap a GitHub OAuth authorization code for an access token. The function holds the client secret and never exposes it to the browser. -- `GET /api/health` is used by the deploy workflow's smoke test; it returns `{ status: "ok", version }` and is unauthenticated. +- The SPA calls `POST /auth/github/exchange` to swap a GitHub OAuth authorization code for an access token. The API holds the OAuth App's client secret (in Key Vault) and never exposes it to the browser. +- Every other call carries `Authorization: Bearer `. The API validates the token and EmergentSoftware org membership and, for publishing, opens the pull request **with that same token** so the PR is authored by the user. +- The API's OpenAPI contract is vendored at `openapi/openapi.json`; `src/lib/api/` is generated from it (`pnpm refresh-openapi && pnpm generate-api`). --- ## 2. One-time prerequisites -Before the pipelines can run end-to-end, the following must exist. +Before the pipeline can run end-to-end, the following must exist. ### 2.1 Production GitHub OAuth App @@ -47,87 +52,60 @@ Register the OAuth App under the **EmergentSoftware** organization (not a person See `docs/OAUTH_APP_SETUP.md` §7–9 for the detailed walkthrough. -### 2.2 Azure Function App configuration +### 2.2 ATK API configuration -The Function App must exist before the deploy workflow can push to it. On the Function App's **Configuration → Application settings** blade, set: +The client id and secret live with the API, not this repo. The **prod** API must be configured with the prod OAuth App's id (`github_oauth_client_id` in the monorepo's `infra/envs/prod/main.tf`) and secret (`github-oauth-client-secret` in the prod Key Vault); the **dev** API uses the dev OAuth App. CORS on both Function Apps must allow the SPA origins (`http://localhost:5173` and `https://emergentsoftware.github.io`; setting `cors_allowed_origins`). See `infra/README.md` in the monorepo. -| Key | Value | Notes | -|---|---|---| -| `GITHUB_OAUTH_CLIENT_ID` | Client ID from §2.1 | Safe to store as plain app setting. | -| `GITHUB_OAUTH_CLIENT_SECRET` | Client secret from §2.1 | Treat as a secret. Consider a Key Vault reference (`@Microsoft.KeyVault(...)`). | -| `CORS_ALLOWED_ORIGINS` | `https://emergentsoftware.github.io` | Must be the **origin** only (scheme + host), no path, no trailing slash. The SPA origin is the Pages host. | - -Notes: - -- Do **not** use the Azure Portal's built-in CORS blade — the function handles CORS itself in `cors.ts`, and the portal's CORS setting would conflict with those headers. Leave the portal CORS list empty. -- After editing app settings the Function App restarts automatically. Wait ~30s before re-running the health probe. - -### 2.3 GitHub repo secrets & variables - -On the `EmergentSoftware/agentic-toolkit-web` repo's **Settings → Secrets and variables → Actions** page: - -**Repository secrets:** - -| Name | Value | -|---|---| -| `AZURE_CREDENTIALS` | Full JSON output of a service principal with Contributor on the Function App's resource group. Create with: `az ad sp create-for-rbac --name "atk-web-deploy" --role contributor --scopes /subscriptions//resourceGroups/ --sdk-auth` — copy the entire JSON blob. | +### 2.3 GitHub repo variables -**Repository variables:** +On the `EmergentSoftware/agentic-toolkit-web` repo's **Settings → Secrets and variables → Actions → Variables** page: | Name | Value | |---|---| -| `VITE_GITHUB_OAUTH_CLIENT_ID` | Client ID from §2.1 (baked into the SPA bundle at build time). | -| `VITE_AUTH_FUNCTION_URL` | `https://.azurewebsites.net/api/auth/exchange` (full URL the SPA posts to). | -| `AZURE_FUNCTION_APP_NAME` | The Azure Function App resource name (the `` portion above, without domain). | +| `VITE_GITHUB_OAUTH_CLIENT_ID` | Client ID of the **prod** OAuth App from §2.1 (baked into the SPA bundle at build time; public). | +| `VITE_ATK_API_URL` | `https://func-atk-prod.azurewebsites.net` (base URL only: no `/api`, no trailing slash). | -The deploy workflow constructs the health URL as `https://${AZURE_FUNCTION_APP_NAME}.azurewebsites.net/api/health`, so the Function App name must resolve publicly under `azurewebsites.net`. +The names must match exactly what `deploy-pages.yml` reads and `src/lib/api-client.ts` / `src/lib/session.ts` expect. No repository secrets are required for the SPA deploy. --- -## 3. Normal deploy flow - -### 3.1 SPA (`deploy-pages.yml`) +## 3. Normal deploy flow (`deploy-pages.yml`) Triggered on push to `main` (and manually via `workflow_dispatch`). Pipeline: -1. **validate** — `pnpm install && pnpm lint && pnpm typecheck && pnpm test` for the web app, then the same sequence in `auth-function/`. A failure here blocks the Pages publish. -2. **build** — `pnpm build` with `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_AUTH_FUNCTION_URL` injected from repo variables. Uploads `./dist` as a Pages artifact. +1. **validate** — `pnpm install && pnpm lint && pnpm typecheck && pnpm test`. A failure here blocks the Pages publish. +2. **build** — `pnpm build` with `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_ATK_API_URL` injected from repo variables. Uploads `./dist` as a Pages artifact. 3. **deploy** — `actions/deploy-pages@v4` publishes the artifact to the `github-pages` environment. The SPA's `base` in `vite.config.ts` is `/agentic-toolkit-web/`, so all built asset URLs are prefixed correctly for the Pages subpath. -### 3.2 Auth function (`deploy-auth-function.yml`) +**Merging to `main` is the production cutover**: there is no staging site. Verify changes locally against the dev API first (`docs/OAUTH_APP_SETUP.md` §6). -Triggered on push to `main` that touches `auth-function/**` (and manually). Pipeline: +### 3.1 Picking up an API contract change -1. `pnpm install --frozen-lockfile` → `pnpm lint` → `pnpm typecheck` → `pnpm test` → `pnpm build` in `auth-function/`. -2. `pnpm prune --prod` — remove dev dependencies so the deploy package only ships what the runtime needs. -3. `azure/login@v2` with `AZURE_CREDENTIALS`. -4. `Azure/functions-action@v1` uploads `auth-function/` (minus `.funcignore` entries) to the Function App named by `AZURE_FUNCTION_APP_NAME`. -5. **Smoke test** — `curl https://.azurewebsites.net/api/health` with up to 6 retries (10s apart). Any non-200 fails the job. +When the API's OpenAPI document changes: -If the smoke test fails, the Function App has already received the new bits; follow the rollback procedure in §5. +```bash +pnpm refresh-openapi # downloads openapi.json from the dev API (ATK_API_URL=… to override) +pnpm generate-api # regenerates src/lib/api/ (committed; never edit by hand) +pnpm typecheck && pnpm test +``` + +Commit `openapi/openapi.json` and `src/lib/api/` together. --- ## 4. Rotating the OAuth client secret (zero downtime) -GitHub supports **two concurrent client secrets per OAuth App**, which lets you cut over without a window where auth is broken. - -1. **Generate a new secret.** - GitHub → EmergentSoftware → OAuth Apps → `ATK Web (production)` → **Generate a new client secret**. Copy it. Both the old and new secrets are now valid simultaneously. -2. **Update the Function App to use the new secret.** - Azure Portal → Function App → Configuration → Application settings → edit `GITHUB_OAUTH_CLIENT_SECRET` → paste the new value → **Save**. The Function App restarts (~30s). New logins now use the new secret; the old secret is still accepted by GitHub, so any login already mid-flight continues to work. -3. **Verify.** - ```bash - curl -sS https://.azurewebsites.net/api/health # expect {"status":"ok",...} - ``` - Then perform a real sign-in against the SPA to confirm the code exchange succeeds. -4. **Delete the old secret.** - Back on the OAuth App page, click the trash icon next to the old secret. From this point only the new secret is accepted. +The client secret is held by the ATK API, so rotation is an API/Key Vault change, not an SPA change. + +1. **Generate a new secret.** GitHub → EmergentSoftware → OAuth Apps → `ATK Web (production)` → **Generate a new client secret**. Both the old and new secrets are valid simultaneously. +2. **Update the prod Key Vault secret** `github-oauth-client-secret` (see the monorepo's `infra/README.md`) and restart the prod Function App if the setting is not picked up automatically. +3. **Verify** by signing in to the SPA on the production URL; the exchange must return `200` with an `access_token`. +4. **Delete the old secret** on the OAuth App page. 5. **Update the password manager** with the new secret and remove the old one. -**Never** delete the old secret on GitHub before the Function App has been updated — that would immediately break production auth. +**Never** delete the old secret on GitHub before the API has the new one — that would immediately break production sign-in. The SPA does not hold the client secret, so no SPA redeploy is ever required for a secret rotation. The **client ID** is public and stable; it does not rotate. @@ -135,8 +113,6 @@ The SPA does not hold the client secret, so no SPA redeploy is ever required for ## 5. Rolling back a bad deploy -### 5.1 Rolling back the SPA - Each successful Pages deploy is a prior run of `deploy-pages.yml`. To roll back: 1. Go to **Actions → Deploy to GitHub Pages**. @@ -147,42 +123,7 @@ Re-running rebuilds the SPA from the same commit SHA that was previously known-g If the root cause is a bad commit already on `main`, revert it: `git revert && git push origin main`. That triggers a fresh `deploy-pages.yml` run with the revert applied. -### 5.2 Rolling back the auth function - -Azure Functions does not keep historical slot snapshots in this configuration (no deployment slots are used). Two options, in order of preference: - -1. **Git revert and redeploy (preferred).** - Revert the offending commit on `main` and push: - ```bash - git revert - git push origin main - ``` - `deploy-auth-function.yml` runs automatically. The health smoke test gates the outcome. - -2. **Re-run the previous good workflow run.** - Actions → Deploy Auth Function → open the last green run → **Re-run all jobs**. This rebuilds and redeploys from the older commit SHA. Note: repo-variable or secret changes since that run (e.g. a rotated secret) remain in effect — only the function code is reverted. - -3. **Emergency manual redeploy.** - Check out the last good commit locally and push a zip deploy: - ```bash - cd auth-function - pnpm install --frozen-lockfile - pnpm build - pnpm prune --prod - zip -r ../deploy.zip . -x '*.test.ts' '__tests__/*' 'local.settings.json' - az functionapp deployment source config-zip \ - --resource-group \ - --name \ - --src ../deploy.zip - ``` - Then verify `/api/health` manually. - -After any rollback, probe the health endpoint before declaring the incident over: - -```bash -curl -sS https://.azurewebsites.net/api/health -# { "status": "ok", "version": "x.y.z" } -``` +An API-side incident (the exchange, `/me`, or registry reads failing) is handled in the monorepo's pipelines, not here. --- @@ -190,8 +131,10 @@ curl -sS https://.azurewebsites.net/api/health | Symptom | Likely cause | Check | |---|---|---| -| Sign-in fails with `origin_not_allowed` | `CORS_ALLOWED_ORIGINS` on the Function App doesn't match the Pages origin exactly. | App setting must be `https://emergentsoftware.github.io` — origin only, no path, no trailing slash. | -| Sign-in fails with `server_misconfigured` | OAuth env vars missing on the Function App. | Confirm `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET` are set on **Configuration → Application settings** (not just local.settings.json). | -| SPA build-time vars empty in bundle | Repo variables renamed or missing. | Variables must be named `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_AUTH_FUNCTION_URL` — these names are baked into `src/lib/session.ts`. | -| Deploy workflow succeeds but health smoke test fails | Function App still cold-starting, or runtime error at startup. | Check Log Stream in the Azure Portal. Re-run the job once; the smoke step retries 6× with 10s backoff, which normally absorbs cold-start. | -| `azure/login` step fails with `AADSTS7000215` | `AZURE_CREDENTIALS` secret is stale or malformed. | Recreate the service principal (`az ad sp create-for-rbac --sdk-auth`) and replace the secret value with the new JSON blob. | +| Sign-in fails with a CORS error in the browser console | The API's `cors_allowed_origins` does not include the SPA origin. | Must contain `https://emergentsoftware.github.io` (prod) / `http://localhost:5173` (dev) — origin only, no path, no trailing slash. | +| Sign-in fails with `Auth exchange failed (HTTP 400): bad_verification_code` | The `code` was already used or expired, or the SPA's client id does not match the API's. | Local dev must use the **dev** OAuth App id (the dev API's id); Pages must use the **prod** id. Start the sign-in over. | +| Sign-in fails with `Auth exchange failed (HTTP 500)` | The API is missing its OAuth configuration. | Check `github_oauth_client_id` and the Key Vault secret for that environment. | +| App shows "Not authorized" for a known org member | `GET /me` returned `403 org_membership_unverifiable`. | SAML SSO not authorized for the token, or the OAuth App is not approved for the org (`https://github.com/orgs/EmergentSoftware/policies/applications`). The browser console logs the hint. | +| App drops back to the signed-out landing on load | `GET /me` returned `401`; the stored token is dead. | Sign in again. | +| `VITE_ATK_API_URL is not set` at startup | Repo variable or `.env.local` missing. | Variables must be named `VITE_GITHUB_OAUTH_CLIENT_ID` and `VITE_ATK_API_URL`. | +| Downloads fail with "The ATK API is unavailable" | API 5xx (usually GitHub upstream). | Retry; check the API's App Insights in Azure. | diff --git a/eslint.config.js b/eslint.config.js index 6206a0c..74c58d7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,7 @@ export default tseslint.config( }, }, { - ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.cjs'], + ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.cjs', 'src/lib/api/**'], }, eslintConfigPrettier, ); diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts new file mode 100644 index 0000000..5cdb095 --- /dev/null +++ b/openapi-ts.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@hey-api/openapi-ts'; + +/** + * Generates the ATK API client from the vendored contract in `openapi/openapi.json`. + * + * - `pnpm refresh-openapi` re-downloads the contract from the API. + * - `pnpm generate-api` regenerates `src/lib/api/` (committed; do not edit by hand). + * + * Only `src/lib/api-client.ts` may import the generated default client. + */ +export default defineConfig({ + input: './openapi/openapi.json', + output: { + path: 'src/lib/api', + postProcess: [], + }, + plugins: [ + { name: '@hey-api/client-fetch', throwOnError: false }, + { name: '@hey-api/sdk', operations: { strategy: 'flat' }, responseStyle: 'fields', throwOnError: false }, + { name: '@hey-api/typescript', enums: false }, + ], +}); diff --git a/openapi/openapi.json b/openapi/openapi.json new file mode 100644 index 0000000..6da73f7 --- /dev/null +++ b/openapi/openapi.json @@ -0,0 +1,1838 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "ATK API", + "version": "0.2.0", + "description": "The Agentic Tool Kit API: registry reads, publish, checkout and web sign-in for the ATK CLI and web app. Authenticate with `Authorization: Bearer `; the caller must be an active member of the EmergentSoftware GitHub organisation." + }, + "servers": [ + { + "url": "https://func-atk-prod.azurewebsites.net", + "description": "Production" + }, + { + "url": "https://func-atk-dev.azurewebsites.net", + "description": "Development" + }, + { + "url": "http://localhost:7071", + "description": "Local `func start`" + } + ], + "paths": { + "/health": { + "get": { + "summary": "Liveness probe", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service is up", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + }, + "operationId": "Health" + } + }, + "/openapi.json": { + "get": { + "summary": "This document", + "tags": [ + "Meta" + ], + "responses": { + "200": { + "description": "OpenAPI 3.1 document", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "operationId": "OpenApi" + } + }, + "/auth/github/exchange": { + "post": { + "summary": "Exchange a GitHub OAuth authorization code for an access token (web sign-in). GitHub's response body is returned verbatim.", + "tags": [ + "Auth" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthExchangeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "GitHub token response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthTokenResponse" + } + } + } + }, + "400": { + "description": "Missing code, or GitHub rejected it (`error` carries GitHub's OAuth error code)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "OAuth app not configured", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "GitHub unreachable or returned an unusable response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "AuthGitHubExchange" + } + }, + "/me": { + "get": { + "summary": "The authenticated caller as resolved by the API", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Principal", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Principal" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "Me", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/registry": { + "get": { + "summary": "The registry index (`registry.json`) at the current commit. Supports `If-None-Match` with the `ETag` returned.", + "tags": [ + "Registry" + ], + "responses": { + "200": { + "description": "registry.json", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "304": { + "description": "Not modified" + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "GetRegistry", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/assets/{type}/{name}/{version}/manifest": { + "get": { + "summary": "The asset version's `manifest.json`", + "tags": [ + "Assets" + ], + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "description": "Asset type", + "schema": { + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + } + }, + { + "name": "name", + "in": "path", + "required": true, + "description": "Asset name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact semver version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "manifest.json", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "GetAssetManifest", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/assets/{type}/{name}/{version}/readme": { + "get": { + "summary": "The asset version's `README.md`", + "tags": [ + "Assets" + ], + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "description": "Asset type", + "schema": { + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + } + }, + { + "name": "name", + "in": "path", + "required": true, + "description": "Asset name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact semver version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Markdown", + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "GetAssetReadme", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/assets/{type}/{name}/{version}/files": { + "get": { + "summary": "Every file in the asset version directory (manifest and README included)", + "tags": [ + "Assets" + ], + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "description": "Asset type", + "schema": { + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + } + }, + { + "name": "name", + "in": "path", + "required": true, + "description": "Asset name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact semver version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileListResponse" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "ListAssetFiles", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/assets/{type}/{name}/{version}/file/{path}": { + "get": { + "summary": "Raw bytes of one file in the asset version directory (path from the `files` listing)", + "tags": [ + "Assets" + ], + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "description": "Asset type", + "schema": { + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + } + }, + { + "name": "name", + "in": "path", + "required": true, + "description": "Asset name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact semver version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "path", + "required": true, + "description": "File path relative to the version directory, e.g. `SKILL.md` or `reference/guide.md`", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File content", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "GetAssetFile", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/assets/{type}/{name}/{version}/download": { + "get": { + "summary": "Zip of the asset and its transitive dependencies. Layout: `{name}/…` plus `dependencies/{dep}/…`. `format=skill` omits the top-level manifest and README and uses the `.skill` extension.", + "tags": [ + "Assets" + ], + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "description": "Asset type", + "schema": { + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + } + }, + { + "name": "name", + "in": "path", + "required": true, + "description": "Asset name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact semver version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "description": "Archive variant", + "schema": { + "type": "string", + "enum": [ + "zip", + "skill" + ] + } + } + ], + "responses": { + "200": { + "description": "Archive", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "DownloadAsset", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/bundles/{name}/{version}/manifest": { + "get": { + "summary": "The bundle version's `bundle.json`", + "tags": [ + "Bundles" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Bundle name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "bundle.json", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "GetBundleManifest", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/bundles/{name}/{version}/readme": { + "get": { + "summary": "The bundle version's `README.md`", + "tags": [ + "Bundles" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Bundle name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Markdown", + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "GetBundleReadme", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/bundles/{name}/{version}/download": { + "get": { + "summary": "Zip of the bundle: `bundle.json` at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and nests skill members as `{member}.skill`.", + "tags": [ + "Bundles" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Bundle name (bare, no @org prefix)", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Exact version, or `latest`", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "description": "Archive variant", + "schema": { + "type": "string", + "enum": [ + "zip", + "skill" + ] + } + } + ], + "responses": { + "200": { + "description": "Archive", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "DownloadBundle", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/checkout/{name}": { + "get": { + "summary": "Everything `atk checkout` needs in one call: the asset resolved by name, with every file base64 encoded.", + "tags": [ + "Assets" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Asset name (bare)", + "schema": { + "type": "string" + } + }, + { + "name": "org", + "in": "query", + "required": false, + "description": "Org scope (bare name, no `@`). Omit for global assets.", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "required": false, + "description": "Asset type; required only when the name exists as more than one type", + "schema": { + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + } + }, + { + "name": "version", + "in": "query", + "required": false, + "description": "Exact version; defaults to `latest`", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Working copy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckoutResponse" + } + } + } + }, + "400": { + "description": "Ambiguous name (exists as several types) or bad type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "Unknown asset, version, or file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "Checkout", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/publish/plan": { + "post": { + "summary": "Validate a publish payload and return the plan (branch, path, PR title and body, files, reviewers, warnings) without touching GitHub.", + "tags": [ + "Publish" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Plan", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishPlan" + } + } + } + }, + "400": { + "description": "Payload failed schema or rule validation (`validation_failed`, `schema_invalid`, `invalid_reviewer`, `reviewers_not_allowed`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "`version_not_bumped`, `version_exists`, or `branch_exists`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "PublishPlan", + "security": [ + { + "githubToken": [] + } + ] + } + }, + "/publish": { + "post": { + "summary": "Validate, then create the branch, commit and pull request on the registry with the caller's own token so the PR is authored by them. Global targets get the default reviewers; org targets use `reviewers`.", + "tags": [ + "Publish" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Pull request opened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishResponse" + } + } + } + }, + "400": { + "description": "Payload failed schema or rule validation (`validation_failed`, `schema_invalid`, `invalid_reviewer`, `reviewers_not_allowed`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "`version_not_bumped`, `version_exists`, or `branch_exists`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "operationId": "Publish", + "security": [ + { + "githubToken": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "githubToken": { + "type": "http", + "scheme": "bearer", + "description": "A GitHub token (OAuth, `gh auth token`, or PAT) with `read:org`, and `repo` for publishing." + } + }, + "schemas": { + "ApiError": { + "description": "Error envelope", + "type": "object", + "properties": { + "error": { + "description": "Machine-readable error code", + "type": "string" + }, + "message": { + "description": "Human-readable message", + "type": "string" + }, + "details": { + "description": "Per-item problems for validation failures", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/ApiErrorDetail" + }, + "default": null + } + }, + "required": [ + "error", + "message" + ] + }, + "ApiErrorDetail": { + "description": "One problem", + "type": "object", + "properties": { + "message": { + "description": "What is wrong", + "type": "string" + }, + "path": { + "description": "JSON pointer into the payload, when applicable", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "message" + ] + }, + "CheckoutFile": { + "description": "One file with content", + "type": "object", + "properties": { + "path": { + "description": "Path relative to the version directory", + "type": "string" + }, + "encoding": { + "description": "Always `base64`", + "type": "string" + }, + "content": { + "description": "Base64 bytes", + "type": "string" + }, + "size": { + "description": "Decoded size in bytes", + "type": "integer" + } + }, + "required": [ + "path", + "encoding", + "content", + "size" + ] + }, + "CheckoutResponse": { + "description": "A working copy of one asset version", + "type": "object", + "properties": { + "name": { + "description": "Asset name", + "type": "string" + }, + "org": { + "description": "Org scope", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Asset type", + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + }, + "version": { + "description": "Resolved version", + "type": "string" + }, + "files": { + "description": "Every file including manifest.json and README.md", + "type": "array", + "items": { + "$ref": "#/components/schemas/CheckoutFile" + } + } + }, + "required": [ + "name", + "type", + "version", + "files" + ] + }, + "FileEntry": { + "description": "A file in a version directory", + "type": "object", + "properties": { + "path": { + "description": "Path relative to the version directory", + "type": "string" + }, + "sha": { + "description": "Git blob sha", + "type": "string" + }, + "size": { + "description": "Size in bytes", + "type": "integer" + } + }, + "required": [ + "path", + "sha", + "size" + ] + }, + "FileListResponse": { + "description": "Directory listing", + "type": "object", + "properties": { + "name": { + "description": "Asset name", + "type": "string" + }, + "org": { + "description": "Org scope", + "type": [ + "string", + "null" + ] + }, + "type": { + "description": "Asset type", + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config" + ] + }, + "version": { + "description": "Resolved version", + "type": "string" + }, + "files": { + "description": "Files, sorted by path", + "type": "array", + "items": { + "$ref": "#/components/schemas/FileEntry" + } + } + }, + "required": [ + "name", + "type", + "version", + "files" + ] + }, + "HealthResponse": { + "description": "Liveness", + "type": "object", + "properties": { + "status": { + "description": "Always `ok`", + "type": "string" + }, + "version": { + "description": "Assembly version", + "type": "string" + }, + "environment": { + "description": "`Development` or `Production`", + "type": "string" + } + }, + "required": [ + "status", + "version", + "environment" + ] + }, + "OAuthExchangeRequest": { + "description": "OAuth code exchange", + "type": "object", + "properties": { + "code": { + "description": "Authorization code from the GitHub redirect", + "type": "string" + } + }, + "required": [ + "code" + ] + }, + "OAuthTokenResponse": { + "description": "GitHub's token response, forwarded as-is", + "type": "object", + "properties": { + "access_token": { + "description": "The user's GitHub token", + "type": "string" + }, + "scope": { + "description": "Granted scopes", + "type": [ + "string", + "null" + ], + "default": null + }, + "token_type": { + "description": "`bearer`", + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "access_token" + ] + }, + "Principal": { + "description": "The authenticated caller", + "type": "object", + "properties": { + "scheme": { + "description": "Auth scheme that validated the token (`github`)", + "type": "string" + }, + "login": { + "description": "GitHub login", + "type": "string" + }, + "name": { + "description": "Display name", + "type": [ + "string", + "null" + ] + }, + "avatarUrl": { + "description": "Avatar URL", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "scheme", + "login" + ] + }, + "PublishFile": { + "description": "A file to commit", + "type": "object", + "properties": { + "path": { + "description": "Path relative to the version directory (no dotfiles, no `..`)", + "type": "string" + }, + "content": { + "description": "File content, text or base64 per `encoding`", + "type": "string" + }, + "encoding": { + "description": "Defaults to `utf8`", + "type": "string", + "enum": [ + "utf8", + "base64" + ], + "default": "utf8" + } + }, + "required": [ + "path", + "content" + ] + }, + "PublishPlan": { + "description": "What a publish would do", + "type": "object", + "properties": { + "kind": { + "description": "asset or bundle", + "type": "string", + "enum": [ + "asset", + "bundle" + ] + }, + "name": { + "description": "Name", + "type": "string" + }, + "org": { + "description": "Org scope", + "type": [ + "string", + "null" + ] + }, + "assetType": { + "description": "Asset type (assets only)", + "type": "string", + "enum": [ + "skill", + "agent", + "rule", + "hook", + "memory-template", + "mcp-config", + null + ] + }, + "version": { + "description": "Version being published", + "type": "string" + }, + "isUpdate": { + "description": "True when the registry already has this asset or bundle", + "type": "boolean" + }, + "previousVersion": { + "description": "The registry's current latest when isUpdate", + "type": [ + "string", + "null" + ] + }, + "branchName": { + "description": "Branch that will be pushed", + "type": "string" + }, + "registryPath": { + "description": "Directory in the registry, with trailing slash", + "type": "string" + }, + "prTitle": { + "description": "Pull request title", + "type": "string" + }, + "prBody": { + "description": "Pull request body (markdown)", + "type": "string" + }, + "files": { + "description": "Files that will be committed, in order", + "type": "array", + "items": { + "type": "string" + } + }, + "reviewers": { + "description": "Reviewers that will be requested", + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "description": "Non-blocking advice (missing README, tags, unlisted files)", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "kind", + "name", + "version", + "isUpdate", + "branchName", + "registryPath", + "prTitle", + "prBody", + "files", + "reviewers", + "warnings" + ] + }, + "PublishRequest": { + "description": "Publish payload", + "type": "object", + "properties": { + "kind": { + "description": "What is being published", + "type": "string", + "enum": [ + "asset", + "bundle" + ] + }, + "manifest": { + "description": "The exact `manifest.json` (kind=asset) or `bundle.json` (kind=bundle) to commit. Validated against the registry's JSON Schema; `files` must be a concrete array.", + "type": "object" + }, + "files": { + "description": "All other files: entrypoint, listed files, README.md. Bundles may only add README.md.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/PublishFile" + }, + "default": null + }, + "message": { + "description": "Optional notes appended to the PR body", + "type": [ + "string", + "null" + ], + "default": null + }, + "reviewers": { + "description": "Reviewers to request. Only allowed for org-scoped targets; global targets always get the default reviewers.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "default": null + }, + "replaceExistingBranch": { + "description": "Delete and recreate the publish branch if it already exists (the CLI's retry behaviour). Default false → 409 `branch_exists`.", + "type": "boolean", + "default": false + }, + "client": { + "description": "Which client is publishing; only affects the PR footer", + "type": "string", + "enum": [ + "cli", + "web", + "api" + ], + "default": "api" + } + }, + "required": [ + "kind", + "manifest" + ] + }, + "PublishResponse": { + "description": "Result of a publish", + "type": "object", + "properties": { + "prUrl": { + "description": "Pull request URL", + "type": "string" + }, + "prNumber": { + "description": "Pull request number", + "type": "integer" + }, + "branchName": { + "description": "Branch pushed", + "type": "string" + }, + "commitSha": { + "description": "Commit created", + "type": "string" + }, + "reviewers": { + "description": "Reviewers requested (the author is skipped)", + "type": "array", + "items": { + "type": "string" + } + }, + "reviewerWarning": { + "description": "Set when the reviewer request failed; the PR still exists", + "type": [ + "string", + "null" + ] + }, + "warnings": { + "description": "Non-blocking advice from validation", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "prUrl", + "prNumber", + "branchName", + "commitSha", + "reviewers", + "warnings" + ] + } + } + } +} diff --git a/package.json b/package.json index 23e1643..089ada0 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,12 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", "test": "vitest run", "test:watch": "vitest", - "sync-schemas": "tsx scripts/sync-schemas.ts" + "sync-schemas": "tsx scripts/sync-schemas.ts", + "refresh-openapi": "tsx scripts/refresh-openapi.ts", + "generate-api": "openapi-ts" }, "dependencies": { "@base-ui-components/react": "^1.0.0-beta.0", - "@octokit/rest": "^22.0.1", "@tanstack/react-form": "^1.29.0", "@tanstack/react-query": "^5.59.0", "@tanstack/react-table": "^8.21.3", @@ -54,6 +55,7 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@hey-api/openapi-ts": "0.99.0", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb50804..c5f948f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@base-ui-components/react': specifier: ^1.0.0-beta.0 version: 1.0.0-rc.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@octokit/rest': - specifier: ^22.0.1 - version: 22.0.1 '@tanstack/react-form': specifier: ^1.29.0 version: 1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -72,6 +69,9 @@ importers: '@eslint/js': specifier: ^9.0.0 version: 9.39.4 + '@hey-api/openapi-ts': + specifier: 0.99.0 + version: 0.99.0(typescript@5.9.3) '@tailwindcss/vite': specifier: ^4.0.0 version: 4.2.2(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) @@ -681,6 +681,31 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@hey-api/codegen-core@0.9.1': + resolution: {integrity: sha512-s97jL1dgTMuiMHv2BZ1X4Tgd99Mf9GOvGdNqNcGwIMmnR+PgYNoraj4Zvp134MKsNCap/m7k0r0vKKnl56pj4w==} + engines: {node: '>=22.18.0'} + + '@hey-api/json-schema-ref-parser@1.4.4': + resolution: {integrity: sha512-otmd+zCxbYVBIp/mlMTnGkvlNYLkVKgs3VOIq0kSnenhB1+fRwLPQIeSwyWM6E51oXhUedkYjVsVpkVexeuJOA==} + engines: {node: '>=22.18.0'} + + '@hey-api/openapi-ts@0.99.0': + resolution: {integrity: sha512-SePU/5oEWWkvUBYmvzdYRctseoLuskyhs4ET0RvLIcmzc8yLQoA2R+KtBIQ8bPsoSUB0m4E5SmBnl6aGSA0szQ==} + engines: {node: '>=22.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' + + '@hey-api/shared@0.5.0': + resolution: {integrity: sha512-JN/j4Ebh4cJGYIQ5cwWuqe7GeSUyQoz7oC51WqyhKOcrejK6DKZMDkshc5d1eKTRuRL+rjozuRcoUaZZn2DGPw==} + engines: {node: '>=22.18.0'} + + '@hey-api/spec-types@0.2.0': + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + + '@hey-api/types@0.1.4': + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -713,57 +738,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@octokit/auth-token@6.0.0': - resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} - engines: {node: '>= 20'} - - '@octokit/core@7.0.6': - resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} - engines: {node: '>= 20'} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - '@octokit/endpoint@11.0.3': - resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} - engines: {node: '>= 20'} - - '@octokit/graphql@9.0.3': - resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} - engines: {node: '>= 20'} - - '@octokit/openapi-types@27.0.0': - resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} - - '@octokit/plugin-paginate-rest@14.0.0': - resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-request-log@6.0.0': - resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/plugin-rest-endpoint-methods@17.0.0': - resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} - engines: {node: '>= 20'} - peerDependencies: - '@octokit/core': '>=6' - - '@octokit/request-error@7.1.0': - resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} - engines: {node: '>= 20'} - - '@octokit/request@10.0.8': - resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} - engines: {node: '>= 20'} - - '@octokit/rest@22.0.1': - resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} - engines: {node: '>= 20'} - - '@octokit/types@16.0.0': - resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1253,6 +1233,10 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1330,9 +1314,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - before-after-hook@4.0.0: - resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -1348,6 +1329,18 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1390,6 +1383,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1404,12 +1401,26 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1472,18 +1483,36 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1501,6 +1530,10 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1653,12 +1686,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fast-content-type-parse@3.0.0: - resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1734,6 +1767,13 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1894,6 +1934,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1913,6 +1958,15 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -1968,6 +2022,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1992,6 +2050,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + jsdom@28.1.0: resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2015,9 +2077,6 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json-with-bigint@3.5.8: - resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2370,6 +2429,13 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2413,6 +2479,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2420,6 +2489,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2428,6 +2500,10 @@ packages: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2454,6 +2530,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + rc9@3.1.0: + resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -2492,6 +2571,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -2546,6 +2629,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -2577,6 +2664,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -2813,9 +2905,6 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - universal-user-agent@7.0.3: - resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2966,6 +3055,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3403,6 +3496,56 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@hey-api/codegen-core@0.9.1': + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4 + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + + '@hey-api/json-schema-ref-parser@1.4.4': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@hey-api/openapi-ts@0.99.0(typescript@5.9.3)': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/shared': 0.5.0 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 15.0.0 + get-tsconfig: 4.14.0 + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + '@hey-api/shared@0.5.0': + dependencies: + '@hey-api/codegen-core': 0.9.1 + '@hey-api/json-schema-ref-parser': 1.4.4 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.8.4 + transitivePeerDependencies: + - magicast + + '@hey-api/spec-types@0.2.0': + dependencies: + '@hey-api/types': 0.1.4 + + '@hey-api/types@0.1.4': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3433,68 +3576,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@octokit/auth-token@6.0.0': {} + '@jsdevtools/ono@7.1.3': {} - '@octokit/core@7.0.6': - dependencies: - '@octokit/auth-token': 6.0.0 - '@octokit/graphql': 9.0.3 - '@octokit/request': 10.0.8 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - before-after-hook: 4.0.0 - universal-user-agent: 7.0.3 - - '@octokit/endpoint@11.0.3': - dependencies: - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/graphql@9.0.3': - dependencies: - '@octokit/request': 10.0.8 - '@octokit/types': 16.0.0 - universal-user-agent: 7.0.3 - - '@octokit/openapi-types@27.0.0': {} - - '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 16.0.0 - - '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - - '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/types': 16.0.0 - - '@octokit/request-error@7.1.0': - dependencies: - '@octokit/types': 16.0.0 - - '@octokit/request@10.0.8': - dependencies: - '@octokit/endpoint': 11.0.3 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - fast-content-type-parse: 3.0.0 - json-with-bigint: 3.5.8 - universal-user-agent: 7.0.3 - - '@octokit/rest@22.0.1': - dependencies: - '@octokit/core': 7.0.6 - '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) - '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) - '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) - - '@octokit/types@16.0.0': - dependencies: - '@octokit/openapi-types': 27.0.0 + '@lukeed/ms@2.0.2': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -3960,6 +4044,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4049,8 +4135,6 @@ snapshots: baseline-browser-mapping@2.10.18: {} - before-after-hook@4.0.0: {} - bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -4072,6 +4156,25 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.1 + giget: 3.3.1 + jiti: 2.6.1 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.3 + rc9: 3.1.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4110,6 +4213,10 @@ snapshots: character-reference-invalid@2.0.1: {} + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -4122,10 +4229,18 @@ snapshots: color-name@1.1.4: {} + color-support@1.1.3: {} + comma-separated-tokens@2.0.3: {} + commander@15.0.0: {} + concat-map@0.0.1: {} + confbox@0.2.4: {} + + confbox@0.3.1: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} @@ -4191,20 +4306,33 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.7: {} + dequal@2.0.3: {} + destr@2.0.5: {} + detect-libc@2.1.2: {} devlop@1.1.0: @@ -4219,6 +4347,8 @@ snapshots: dom-accessibility-api@0.6.3: {} + dotenv@17.4.2: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4525,9 +4655,9 @@ snapshots: expect-type@1.3.0: {} - extend@3.0.2: {} + exsolve@1.1.1: {} - fast-content-type-parse@3.0.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -4607,6 +4737,12 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@3.3.1: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -4789,6 +4925,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -4809,6 +4947,12 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -4861,6 +5005,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -4884,6 +5032,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + jsdom@28.1.0: dependencies: '@acemir/cssom': 0.9.31 @@ -4919,8 +5071,6 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - json-with-bigint@3.5.8: {} - json5@2.2.3: {} jsx-ast-utils@3.3.5: @@ -5455,6 +5605,17 @@ snapshots: obug@2.1.1: {} + ohash@2.0.12: {} + + open@11.0.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5506,10 +5667,18 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.1 + pathe: 2.0.3 + possible-typed-array-names@1.1.0: {} postcss@8.5.9: @@ -5518,6 +5687,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} prettier@3.8.2: {} @@ -5540,6 +5711,11 @@ snapshots: punycode@2.3.1: {} + rc9@3.1.0: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 @@ -5589,6 +5765,8 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readdirp@5.1.1: {} + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -5709,6 +5887,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 + run-applescript@7.1.0: {} + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.9 @@ -5740,6 +5920,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.4: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -6037,8 +6219,6 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - universal-user-agent@7.0.3: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -6176,6 +6356,11 @@ snapshots: word-wrap@1.2.5: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/scripts/refresh-openapi.ts b/scripts/refresh-openapi.ts new file mode 100644 index 0000000..59282a8 --- /dev/null +++ b/scripts/refresh-openapi.ts @@ -0,0 +1,23 @@ +/** + * Download the ATK API's OpenAPI contract into `openapi/openapi.json`. + * + * Usage: `pnpm refresh-openapi` (defaults to the dev API) or + * `ATK_API_URL=https://func-atk-prod.azurewebsites.net pnpm refresh-openapi`. + * + * Run `pnpm generate-api` afterwards to regenerate `src/lib/api/`. + */ +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const baseUrl = (process.env['ATK_API_URL'] ?? 'https://func-atk-dev.azurewebsites.net').replace(/\/$/, ''); +const url = `${baseUrl}/openapi.json`; +const target = join(process.cwd(), 'openapi', 'openapi.json'); + +const response = await fetch(url); +if (!response.ok) { + throw new Error(`Failed to download ${url}: HTTP ${response.status}`); +} + +const spec = (await response.json()) as Record; +await writeFile(target, JSON.stringify(spec, null, 2) + '\n', 'utf-8'); +console.log(`Wrote ${target} from ${url} (info.version ${(spec['info'] as { version?: string })?.version ?? '?'})`); diff --git a/src/__tests__/lib/api-client.test.ts b/src/__tests__/lib/api-client.test.ts new file mode 100644 index 0000000..63a45c9 --- /dev/null +++ b/src/__tests__/lib/api-client.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getRegistry, me } from '@/lib/api'; +import { ApiRequestError, createApiClient, getApiUrl, isApiError, unwrap } from '@/lib/api-client'; + +import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch, textResponse } from '../utils/api-stub'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.stubEnv('VITE_ATK_API_URL', 'http://localhost:7071'); +}); + +describe('getApiUrl', () => { + it('reads VITE_ATK_API_URL and strips trailing slashes', () => { + vi.stubEnv('VITE_ATK_API_URL', 'https://func-atk-dev.azurewebsites.net//'); + expect(getApiUrl()).toBe('https://func-atk-dev.azurewebsites.net'); + }); + + it('throws the copy-.env.example error when unset', () => { + vi.stubEnv('VITE_ATK_API_URL', ''); + expect(() => getApiUrl()).toThrow(/VITE_ATK_API_URL is not set.*\.env\.example/); + }); +}); + +describe('createApiClient', () => { + it('sends the bearer token and routes requests through the configured base URL', async () => { + const { calls } = stubFetch(() => jsonResponse({ login: 'octo', scheme: 'github' })); + + const result = await me({ client: makeTestApiClient('gho_abc') }); + + expect(result.response?.status).toBe(200); + expect(calls[0]!.url).toBe('http://localhost:7071/me'); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_abc'); + }); + + it('omits the Authorization header when built without a token', async () => { + const { calls } = stubFetch(() => jsonResponse({})); + + await getRegistry({ client: createApiClient(null, { retry: { maxRetries: 0 } }) }); + + expect(calls[0]!.headers.get('authorization')).toBeNull(); + }); + + it('retries transient failures through fetchWithRetry', async () => { + const fixture = { assets: [], version: 'x' }; + const { calls } = stubFetch((_req, index) => (index === 0 ? textResponse('', 503) : jsonResponse(fixture))); + + const result = await getRegistry({ client: makeTestApiClient() }); + + expect(calls).toHaveLength(2); + expect(result.data).toEqual(fixture); + }); +}); + +describe('unwrap', () => { + it('returns data for a 2xx result', () => { + expect(unwrap({ data: { ok: true }, response: new Response(null, { status: 200 }) }, 'thing')).toEqual({ + ok: true, + }); + }); + + it('maps a thrown fetch to a network_error with status 0', () => { + const err = catchError(() => unwrap({ error: new TypeError('offline') }, 'the registry index')); + expect(isApiError(err, 'network_error', 0)).toBe(true); + expect(err.message).toMatch(/could not reach the atk api.*offline/i); + }); + + it('uses the API error code and message from the envelope', () => { + const err = catchError(() => + unwrap( + { + error: { error: 'version_not_bumped', message: '1.0.0 is not newer than 1.2.0' }, + response: new Response(null, { status: 409 }), + }, + 'the publish request', + ), + ); + expect(err).toBeInstanceOf(ApiRequestError); + expect(err.code).toBe('version_not_bumped'); + expect(err.status).toBe(409); + expect(err.message).toBe('1.0.0 is not newer than 1.2.0'); + }); + + it('carries validation details through', () => { + const details = [{ message: 'must match pattern', path: '/manifest/name' }]; + const err = catchError(() => + unwrap( + { + error: { details, error: 'schema_invalid', message: 'Manifest failed schema validation' }, + response: new Response(null, { status: 400 }), + }, + 'the publish request', + ), + ); + expect(err.code).toBe('schema_invalid'); + expect(err.details).toEqual(details); + }); + + it('describes 401, non-member 403, 404, 429 and 5xx in user-facing terms', () => { + const at = (status: number, error?: unknown) => + catchError(() => unwrap({ error: error ?? {}, response: new Response(null, { status }) }, 'the registry index')); + + expect(at(401).message).toMatch(/sign in again/i); + expect(at(403, { error: 'not_org_member', message: 'nope' }).message).toMatch(/EmergentSoftware/); + expect(at(403, { error: 'not_org_member', message: 'nope' }).code).toBe('not_org_member'); + expect(at(404).message).toMatch(/was not found/i); + expect(at(429).message).toMatch(/rate limiting/i); + expect(at(503, { error: 'upstream_unavailable', message: 'GitHub timed out' }).message).toMatch( + /unavailable.*HTTP 503.*GitHub timed out/i, + ); + }); + + it('falls back to http_error with the raw body when there is no envelope', () => { + const err = catchError(() => + unwrap({ error: 'Bad Gateway', response: new Response(null, { status: 502 }) }, 'the registry index'), + ); + expect(err.code).toBe('http_error'); + expect(err.status).toBe(502); + }); + + it('isApiError narrows by code and status', () => { + const err = new ApiRequestError('x', { code: 'not_found', resource: 'r', status: 404 }); + expect(isApiError(err)).toBe(true); + expect(isApiError(err, 'not_found')).toBe(true); + expect(isApiError(err, 'not_found', 404)).toBe(true); + expect(isApiError(err, 'branch_exists')).toBe(false); + expect(isApiError(err, undefined, 409)).toBe(false); + expect(isApiError(new Error('x'))).toBe(false); + }); +}); + +it('carries a real API envelope through the generated client into ApiRequestError', async () => { + stubFetch(() => apiErrorResponse(403, 'org_membership_unverifiable', 'SAML SSO required')); + + const result = await me({ client: makeTestApiClient() }); + const mapped = catchError(() => unwrap(result, 'your GitHub account')); + expect(mapped.code).toBe('org_membership_unverifiable'); + expect(mapped.status).toBe(403); + expect(mapped.resource).toBe('your GitHub account'); +}); + +function catchError(fn: () => unknown): ApiRequestError { + try { + fn(); + } catch (error) { + return error as ApiRequestError; + } + throw new Error('expected the call to throw'); +} diff --git a/src/__tests__/lib/download-service.test.ts b/src/__tests__/lib/download-service.test.ts index a0d86ec..2d89f03 100644 --- a/src/__tests__/lib/download-service.test.ts +++ b/src/__tests__/lib/download-service.test.ts @@ -1,724 +1,187 @@ -import JSZip from 'jszip'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Manifest } from '@/lib/schemas'; -import type { Bundle } from '@/lib/schemas'; - import { downloadAsset, downloadBundle } from '@/lib/download-service'; import { RegistryFetchError, RegistryNotFoundError } from '@/lib/registry-errors'; -const fastRetry = { baseDelayMs: 1, jitter: false, maxDelayMs: 5, maxRetries: 2 } as const; - -interface FakeAsset { - files: Record; - manifest: Manifest; - org?: string; -} +import { + API_BASE, + apiErrorResponse, + blobResponse, + makeTestApiClient, + stubFetch, + textResponse, +} from '../utils/api-stub'; -function buildManifestBytes(manifest: Manifest): string { - return JSON.stringify(manifest, null, 2); -} +const client = makeTestApiClient('tok'); -function buildUrl( - type: string, - name: string, - version: string, - path: string, - org?: string, -): string { - const typeDir = `${type}s`; - const parts = ['assets', typeDir]; - if (org) parts.push(`@${encodeURIComponent(org)}`); - parts.push(encodeURIComponent(name), encodeURIComponent(version), path); - return `https://api.github.com/repos/EmergentSoftware/agentic-toolkit-registry/contents/${parts.join('/')}`; -} - -function encodeBase64Text(text: string): { content: string; encoding: 'base64' } { - const bytes = new TextEncoder().encode(text); - let binary = ''; - for (const b of bytes) binary += String.fromCharCode(b); - return { content: btoa(binary), encoding: 'base64' }; -} +/** Fake zip bytes; the API builds real archives, the client only forwards them. */ +const ZIP_BYTES = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x01, 0x02, 0x03]); -function okResponse(envelope: unknown): Response { - return new Response(JSON.stringify(envelope), { status: 200 }); +async function bytesOf(blob: Blob): Promise { + return Array.from(new Uint8Array(await blob.arrayBuffer())); } -async function readZipEntries(blob: Blob): Promise> { - const ab = await blob.arrayBuffer(); - const zip = await JSZip.loadAsync(ab); - const entries: Record = {}; - for (const [path, file] of Object.entries(zip.files)) { - if (file.dir) continue; - entries[path] = await file.async('string'); - } - return entries; -} - -function setupFetchForAssets(assets: FakeAsset[]): ReturnType { - const urlMap = new Map Response) | Response>(); - for (const asset of assets) { - const { name, type, version } = asset.manifest; - const manifestText = buildManifestBytes(asset.manifest); - urlMap.set(buildUrl(type, name, version, 'manifest.json', asset.org), okResponse(encodeBase64Text(manifestText))); - for (const [path, contents] of Object.entries(asset.files)) { - urlMap.set(buildUrl(type, name, version, path, asset.org), okResponse(encodeBase64Text(contents))); - } - } - - return vi.fn(async (url: RequestInfo | URL) => { - const key = String(url); - const entry = urlMap.get(key); - if (!entry) { - return new Response('', { status: 404 }); - } - return typeof entry === 'function' ? entry() : entry.clone(); - }); +function parseUrl(url: string): { path: string; query: Record } { + const parsed = new URL(url); + return { path: parsed.pathname, query: Object.fromEntries(parsed.searchParams.entries()) }; } describe('downloadAsset', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it('packages a single asset inside a {name}/ folder and preserves manifest bytes', async () => { - const manifest: Manifest = { - author: 'EmergentSoftware', - description: 'validate', - entrypoint: 'AGENT.md', - files: ['AGENT.md'], - name: 'validate', - org: 'agentic-toolkit', - type: 'agent', - version: '1.1.0', - }; - const readme = '# validate\n'; - const agent = 'agent body'; - - const fetchMock = setupFetchForAssets([ - { - files: { 'AGENT.md': agent, 'README.md': readme }, - manifest, - org: 'agentic-toolkit', - }, - ]); - vi.stubGlobal('fetch', fetchMock); - + it('fetches the zip from the download endpoint and hands the blob to the browser', async () => { + const { calls } = stubFetch(() => blobResponse(ZIP_BYTES)); const trigger = vi.fn(); + const result = await downloadAsset( { name: 'validate', org: 'agentic-toolkit', type: 'agent', version: '1.1.0' }, - { retry: fastRetry, triggerDownload: trigger }, + { client, triggerDownload: trigger }, ); expect(result.filename).toBe('validate-1.1.0.zip'); - const entries = await readZipEntries(result.blob); - expect(Object.keys(entries).sort()).toEqual([ - 'validate/AGENT.md', - 'validate/README.md', - 'validate/manifest.json', - ]); - expect(entries['validate/manifest.json']).toBe(buildManifestBytes(manifest)); - expect(entries['validate/AGENT.md']).toBe(agent); - expect(entries['validate/README.md']).toBe(readme); + expect(await bytesOf(result.blob)).toEqual(Array.from(ZIP_BYTES)); expect(trigger).toHaveBeenCalledWith(result.blob, 'validate-1.1.0.zip'); - }); - - it('preserves nested subdirectories declared in manifest.files', async () => { - const manifest: Manifest = { - author: 'EmergentSoftware', - description: 'skill with refs', - entrypoint: 'SKILL.md', - files: [ - 'SKILL.md', - 'references/data-conventions.md', - 'references/naming-conventions.md', - ], - name: 'transactional-sql', - type: 'skill', - version: '1.0.2', - }; - - const fetchMock = setupFetchForAssets([ - { - files: { - 'README.md': '# readme', - 'references/data-conventions.md': 'data conv', - 'references/naming-conventions.md': 'naming conv', - 'SKILL.md': '# skill', - }, - manifest, - }, - ]); - vi.stubGlobal('fetch', fetchMock); - - const { blob } = await downloadAsset( - { name: 'transactional-sql', type: 'skill', version: '1.0.2' }, - { retry: fastRetry, triggerDownload: vi.fn() }, - ); - - const entries = await readZipEntries(blob); - expect(Object.keys(entries).sort()).toEqual([ - 'transactional-sql/README.md', - 'transactional-sql/SKILL.md', - 'transactional-sql/manifest.json', - 'transactional-sql/references/data-conventions.md', - 'transactional-sql/references/naming-conventions.md', - ]); - expect(entries['transactional-sql/references/data-conventions.md']).toBe('data conv'); - expect(entries['transactional-sql/references/naming-conventions.md']).toBe('naming conv'); - }); - - it('tolerates a missing README (HTTP 404) on the primary asset', async () => { - const manifest: Manifest = { - author: 'community', - description: 'no readme', - entrypoint: 'SKILL.md', - name: 'no-readme', - type: 'skill', - version: '1.0.0', - }; - - const fetchMock = setupFetchForAssets([ - { files: { 'SKILL.md': 'body' }, manifest }, - ]); - vi.stubGlobal('fetch', fetchMock); - - const { blob } = await downloadAsset( - { name: 'no-readme', type: 'skill', version: '1.0.0' }, - { retry: fastRetry, triggerDownload: vi.fn() }, - ); - const entries = await readZipEntries(blob); - expect(Object.keys(entries).sort()).toEqual(['no-readme/SKILL.md', 'no-readme/manifest.json']); - }); - - it('places each dependency under dependencies/{name}/ with its own files', async () => { - const primary: Manifest = { - author: 'EmergentSoftware', - dependencies: [{ name: 'dev-commands-rule', type: 'rule', version: '1.2.0' }], - description: 'primary', - entrypoint: 'AGENT.md', - name: 'validate', - type: 'agent', - version: '1.1.0', - }; - const dep: Manifest = { - author: 'EmergentSoftware', - description: 'dep', - entrypoint: 'RULE.md', - name: 'dev-commands-rule', - type: 'rule', - version: '1.2.0', - }; - - const fetchMock = setupFetchForAssets([ - { files: { 'AGENT.md': 'agent', 'README.md': 'primary readme' }, manifest: primary }, - { files: { 'README.md': 'dep readme', 'RULE.md': 'rule body' }, manifest: dep }, - ]); - vi.stubGlobal('fetch', fetchMock); - - const { blob } = await downloadAsset( - { name: 'validate', type: 'agent', version: '1.1.0' }, - { retry: fastRetry, triggerDownload: vi.fn() }, - ); - - const entries = await readZipEntries(blob); - expect(Object.keys(entries).sort()).toEqual([ - 'dependencies/dev-commands-rule/README.md', - 'dependencies/dev-commands-rule/RULE.md', - 'dependencies/dev-commands-rule/manifest.json', - 'validate/AGENT.md', - 'validate/README.md', - 'validate/manifest.json', - ]); - expect(entries['dependencies/dev-commands-rule/manifest.json']).toBe(buildManifestBytes(dep)); - expect(entries['validate/manifest.json']).toBe(buildManifestBytes(primary)); + expect(calls).toHaveLength(1); + expect(calls[0]!.method).toBe('GET'); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer tok'); + expect(parseUrl(calls[0]!.url)).toEqual({ + path: '/assets/agent/validate/1.1.0/download', + query: { format: 'zip', org: 'agentic-toolkit' }, + }); + expect(calls[0]!.url.startsWith(API_BASE)).toBe(true); }); - it('omits the top-level manifest.json and README.md and uses a .skill extension for format: skill', async () => { - const manifest: Manifest = { - author: 'EmergentSoftware', - description: 'a skill', - entrypoint: 'SKILL.md', - files: ['SKILL.md', 'references/data.md'], - name: 'my-skill', - type: 'skill', - version: '2.0.0', - }; - - const fetchMock = setupFetchForAssets([ - { - files: { - 'README.md': '# readme', - 'references/data.md': 'data', - 'SKILL.md': '# skill', - }, - manifest, - }, - ]); - vi.stubGlobal('fetch', fetchMock); - + it('requests format=skill and uses the .skill extension', async () => { + const { calls } = stubFetch(() => blobResponse(ZIP_BYTES)); const trigger = vi.fn(); + const result = await downloadAsset( { name: 'my-skill', type: 'skill', version: '2.0.0' }, - { format: 'skill', retry: fastRetry, triggerDownload: trigger }, + { client, format: 'skill', triggerDownload: trigger }, ); expect(result.filename).toBe('my-skill-2.0.0.skill'); - const entries = await readZipEntries(result.blob); - expect(Object.keys(entries).sort()).toEqual([ - 'my-skill/SKILL.md', - 'my-skill/references/data.md', - ]); - expect(entries['my-skill/SKILL.md']).toBe('# skill'); - expect(entries['my-skill/references/data.md']).toBe('data'); expect(trigger).toHaveBeenCalledWith(result.blob, 'my-skill-2.0.0.skill'); + expect(parseUrl(calls[0]!.url)).toEqual({ + path: '/assets/skill/my-skill/2.0.0/download', + query: { format: 'skill' }, + }); }); - it('strips metadata only from the top-level skill, leaving dependency manifests intact for format: skill', async () => { - const primary: Manifest = { - author: 'EmergentSoftware', - dependencies: [{ name: 'dev-commands-rule', type: 'rule', version: '1.2.0' }], - description: 'primary', - entrypoint: 'SKILL.md', - name: 'my-skill', - type: 'skill', - version: '1.0.0', - }; - const dep: Manifest = { - author: 'EmergentSoftware', - description: 'dep', - entrypoint: 'RULE.md', - name: 'dev-commands-rule', - type: 'rule', - version: '1.2.0', - }; - - const fetchMock = setupFetchForAssets([ - { files: { 'README.md': 'primary readme', 'SKILL.md': 'skill body' }, manifest: primary }, - { files: { 'README.md': 'dep readme', 'RULE.md': 'rule body' }, manifest: dep }, - ]); - vi.stubGlobal('fetch', fetchMock); - - const { blob } = await downloadAsset( - { name: 'my-skill', type: 'skill', version: '1.0.0' }, - { format: 'skill', retry: fastRetry, triggerDownload: vi.fn() }, + it('retries transient 5xx responses and eventually succeeds', async () => { + const { calls } = stubFetch((_req, index) => + index === 0 ? textResponse('', 503, 'text/plain') : blobResponse(ZIP_BYTES), ); - const entries = await readZipEntries(blob); - expect(Object.keys(entries).sort()).toEqual([ - 'dependencies/dev-commands-rule/README.md', - 'dependencies/dev-commands-rule/RULE.md', - 'dependencies/dev-commands-rule/manifest.json', - 'my-skill/SKILL.md', - ]); - expect(entries['dependencies/dev-commands-rule/manifest.json']).toBe(buildManifestBytes(dep)); - }); - - it('deduplicates dependencies encountered via multiple paths and guards against cycles', async () => { - const a: Manifest = { - author: 'x', - dependencies: [{ name: 'b', type: 'skill', version: '1.0.0' }], - description: 'a', - entrypoint: 'A.md', - name: 'a', - type: 'skill', - version: '1.0.0', - }; - const b: Manifest = { - author: 'x', - dependencies: [{ name: 'a', type: 'skill', version: '1.0.0' }], - description: 'b', - entrypoint: 'B.md', - name: 'b', - type: 'skill', - version: '1.0.0', - }; - const fetchMock = setupFetchForAssets([ - { files: { 'A.md': 'a' }, manifest: a }, - { files: { 'B.md': 'b' }, manifest: b }, - ]); - vi.stubGlobal('fetch', fetchMock); - const { blob } = await downloadAsset( - { name: 'a', type: 'skill', version: '1.0.0' }, - { retry: fastRetry, triggerDownload: vi.fn() }, + { name: 'flaky', type: 'skill', version: '1.0.0' }, + { client, triggerDownload: vi.fn() }, ); - const entries = await readZipEntries(blob); - // Cycle should resolve: primary a under a/, b under dependencies/b/ - expect(Object.keys(entries)).toContain('a/A.md'); - expect(Object.keys(entries)).toContain('dependencies/b/B.md'); - expect(Object.keys(entries)).toContain('dependencies/b/manifest.json'); - // a should not recurse into itself as a dep - expect(Object.keys(entries)).not.toContain('dependencies/a/A.md'); + expect(calls).toHaveLength(2); + expect(await bytesOf(blob)).toEqual(Array.from(ZIP_BYTES)); }); - it('retries transient 5xx responses and eventually succeeds', async () => { - const manifest: Manifest = { - author: 'x', - description: 'flaky', - entrypoint: 'SKILL.md', - name: 'flaky', - type: 'skill', - version: '1.0.0', - }; - - const manifestUrl = buildUrl('skill', 'flaky', '1.0.0', 'manifest.json'); - const skillUrl = buildUrl('skill', 'flaky', '1.0.0', 'SKILL.md'); - const readmeUrl = buildUrl('skill', 'flaky', '1.0.0', 'README.md'); - - let manifestCalls = 0; - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - const key = String(url); - if (key === manifestUrl) { - manifestCalls += 1; - if (manifestCalls === 1) return new Response('', { status: 503 }); - return okResponse(encodeBase64Text(buildManifestBytes(manifest))); - } - if (key === skillUrl) return okResponse(encodeBase64Text('body')); - if (key === readmeUrl) return new Response('', { status: 404 }); - return new Response('', { status: 404 }); - }); - vi.stubGlobal('fetch', fetchMock); + it('surfaces RegistryNotFoundError when the asset version is unknown', async () => { + stubFetch(() => apiErrorResponse(404, 'not_found', 'Unknown asset version')); - const { blob } = await downloadAsset( - { name: 'flaky', type: 'skill', version: '1.0.0' }, - { retry: fastRetry, triggerDownload: vi.fn() }, - ); - expect(manifestCalls).toBeGreaterThanOrEqual(2); - const entries = await readZipEntries(blob); - expect(entries['flaky/SKILL.md']).toBe('body'); + await expect( + downloadAsset({ name: 'broken', type: 'skill', version: '1.0.0' }, { client, triggerDownload: vi.fn() }), + ).rejects.toBeInstanceOf(RegistryNotFoundError); }); - it('surfaces a typed error when a required file is missing', async () => { - // Manifest fetch succeeds but the entrypoint returns 404 → non-tolerated miss - const manifest: Manifest = { - author: 'x', - description: 'broken', - entrypoint: 'SKILL.md', - name: 'broken', - type: 'skill', - version: '1.0.0', - }; - const manifestUrl = buildUrl('skill', 'broken', '1.0.0', 'manifest.json'); - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - const key = String(url); - if (key === manifestUrl) return okResponse(encodeBase64Text(buildManifestBytes(manifest))); - return new Response('', { status: 404 }); - }); - vi.stubGlobal('fetch', fetchMock); + it('surfaces a RegistryFetchError with the status for non-retryable HTTP failures', async () => { + stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not a member')); - await expect( - downloadAsset( - { name: 'broken', type: 'skill', version: '1.0.0' }, - { retry: fastRetry, triggerDownload: vi.fn() }, - ), - ).rejects.toBeInstanceOf(RegistryNotFoundError); + const error = await downloadAsset( + { name: 'forbidden', type: 'skill', version: '1.0.0' }, + { client, triggerDownload: vi.fn() }, + ).catch((e) => e); + + expect(error).toBeInstanceOf(RegistryFetchError); + expect((error as RegistryFetchError).status).toBe(403); }); - it('surfaces a RegistryFetchError for non-retryable HTTP failures', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 403 })); - vi.stubGlobal('fetch', fetchMock); + it('surfaces a RegistryFetchError when the API is unreachable', async () => { + stubFetch(() => { + throw new TypeError('offline'); + }); await expect( - downloadAsset( - { name: 'forbidden', type: 'skill', version: '1.0.0' }, - { retry: fastRetry, triggerDownload: vi.fn() }, - ), + downloadAsset({ name: 'offline', type: 'skill', version: '1.0.0' }, { client, triggerDownload: vi.fn() }), ).rejects.toBeInstanceOf(RegistryFetchError); }); }); -function buildBundleUrl(name: string, version: string, org?: string): string { - const parts = ['bundles']; - if (org) parts.push(`@${encodeURIComponent(org)}`); - parts.push(encodeURIComponent(name), encodeURIComponent(version), 'bundle.json'); - return `https://api.github.com/repos/EmergentSoftware/agentic-toolkit-registry/contents/${parts.join('/')}`; -} - describe('downloadBundle', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it('packages bundle.json at the root and each member under {memberName}/ with flat files', async () => { - const bundle: Bundle = { - assets: [ - { name: 'clarification-agent', type: 'agent' }, - { name: 'validate', org: 'agentic-toolkit', type: 'agent', version: '1.1.0' }, - ], - author: 'EmergentSoftware', - description: 'feature workflow', - name: 'feature-workflow', - setupInstructions: '## setup', - tags: ['workflow'], - version: '1.0.0', - }; - - const clarificationManifest: Manifest = { - author: 'community', - description: 'clarifier', - entrypoint: 'AGENT.md', - files: ['AGENT.md'], - name: 'clarification-agent', - type: 'agent', - version: '1.0.0', - }; - const validateManifest: Manifest = { - author: 'EmergentSoftware', - description: 'validate', - entrypoint: 'AGENT.md', - files: ['AGENT.md'], - name: 'validate', - org: 'agentic-toolkit', - type: 'agent', - version: '1.1.0', - }; - - const assetFetch = setupFetchForAssets([ - { files: { 'AGENT.md': 'clarifier body', 'README.md': 'clarifier readme' }, manifest: clarificationManifest }, - { - files: { 'AGENT.md': 'validate body', 'README.md': 'validate readme' }, - manifest: validateManifest, - org: 'agentic-toolkit', - }, - ]); - - const bundleUrl = buildBundleUrl('feature-workflow', '1.0.0'); - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - if (String(url) === bundleUrl) return okResponse(encodeBase64Text(JSON.stringify(bundle, null, 2))); - return (assetFetch as unknown as (u: RequestInfo | URL) => Promise)(url); - }); - vi.stubGlobal('fetch', fetchMock); - + it('fetches the bundle zip and names it {name}-{version}.zip', async () => { + const { calls } = stubFetch(() => blobResponse(ZIP_BYTES)); const trigger = vi.fn(); - const result = await downloadBundle('feature-workflow', { - resolveVersion: (member) => (member.name === 'clarification-agent' ? '1.0.0' : undefined), - retry: fastRetry, - triggerDownload: trigger, - version: '1.0.0', - }); + + const result = await downloadBundle('feature-workflow', { client, triggerDownload: trigger, version: '1.0.0' }); expect(result.filename).toBe('feature-workflow-1.0.0.zip'); - const entries = await readZipEntries(result.blob); - const keys = Object.keys(entries).sort(); - expect(keys).toContain('bundle.json'); - expect(keys).toContain('clarification-agent/AGENT.md'); - expect(keys).toContain('clarification-agent/README.md'); - expect(keys).toContain('clarification-agent/manifest.json'); - expect(keys).toContain('validate/AGENT.md'); - expect(keys).toContain('validate/manifest.json'); - expect(entries['bundle.json']).toContain('"feature-workflow"'); + expect(await bytesOf(result.blob)).toEqual(Array.from(ZIP_BYTES)); expect(trigger).toHaveBeenCalledWith(result.blob, 'feature-workflow-1.0.0.zip'); + expect(parseUrl(calls[0]!.url)).toEqual({ + path: '/bundles/feature-workflow/1.0.0/download', + query: { format: 'zip' }, + }); }); - it('format: skill nests skill members as .skill files, strips non-skill metadata, and drops bundle.json', async () => { - const bundle: Bundle = { - assets: [ - { name: 'code-reviewer', type: 'skill', version: '2.0.0' }, - { name: 'validate', type: 'agent', version: '1.1.0' }, - ], - author: 'EmergentSoftware', - description: 'review workflow', - name: 'review-workflow', - version: '1.0.0', - }; - - const skillManifest: Manifest = { - author: 'EmergentSoftware', - description: 'reviewer', - entrypoint: 'SKILL.md', - files: ['SKILL.md'], - name: 'code-reviewer', - type: 'skill', - version: '2.0.0', - }; - const agentManifest: Manifest = { - author: 'EmergentSoftware', - description: 'validate', - entrypoint: 'AGENT.md', - files: ['AGENT.md'], - name: 'validate', - type: 'agent', - version: '1.1.0', - }; - - const assetFetch = setupFetchForAssets([ - { files: { 'README.md': 'skill readme', 'SKILL.md': 'skill body' }, manifest: skillManifest }, - { files: { 'AGENT.md': 'agent body', 'README.md': 'agent readme' }, manifest: agentManifest }, - ]); - const bundleUrl = buildBundleUrl('review-workflow', '1.0.0'); - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - if (String(url) === bundleUrl) return okResponse(encodeBase64Text(JSON.stringify(bundle, null, 2))); - return (assetFetch as unknown as (u: RequestInfo | URL) => Promise)(url); - }); - vi.stubGlobal('fetch', fetchMock); + it('keeps the .zip extension for the skill variant and requests format=skill', async () => { + const { calls } = stubFetch(() => blobResponse(ZIP_BYTES)); const result = await downloadBundle('review-workflow', { + client, format: 'skill', - retry: fastRetry, triggerDownload: vi.fn(), version: '1.0.0', }); - // Outer archive keeps the .zip extension even for the .skill variant. expect(result.filename).toBe('review-workflow-1.0.0.zip'); - - const outer = await JSZip.loadAsync(await result.blob.arrayBuffer()); - const outerKeys = Object.keys(outer.files).filter((k) => !outer.files[k].dir); - - // bundle.json is dropped. - expect(outerKeys).not.toContain('bundle.json'); - - // The skill member is a nested .skill file… - expect(outerKeys).toContain('code-reviewer.skill'); - const nestedBytes = await outer.file('code-reviewer.skill')!.async('uint8array'); - const nested = await JSZip.loadAsync(nestedBytes); - const nestedKeys = Object.keys(nested.files).filter((k) => !nested.files[k].dir); - // …and is itself a metadata-stripped skill archive. - expect(nestedKeys).toContain('code-reviewer/SKILL.md'); - expect(nestedKeys).not.toContain('code-reviewer/manifest.json'); - expect(nestedKeys).not.toContain('code-reviewer/README.md'); - - // The non-skill member stays a folder but with its metadata stripped. - expect(outerKeys).toContain('validate/AGENT.md'); - expect(outerKeys).not.toContain('validate/manifest.json'); - expect(outerKeys).not.toContain('validate/README.md'); + expect(parseUrl(calls[0]!.url).query).toEqual({ format: 'skill' }); }); - it('falls back to resolveVersion when a member omits its version', async () => { - const bundle: Bundle = { - assets: [{ name: 'clarification-agent', type: 'agent' }], - author: 'x', - description: 'b', - name: 'tiny', - version: '0.1.0', - }; - const manifest: Manifest = { - author: 'x', - description: 'c', - entrypoint: 'AGENT.md', - name: 'clarification-agent', - type: 'agent', - version: '2.3.4', - }; - - const assetFetch = setupFetchForAssets([{ files: { 'AGENT.md': 'body' }, manifest }]); - const bundleUrl = buildBundleUrl('tiny', '0.1.0'); - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - if (String(url) === bundleUrl) return okResponse(encodeBase64Text(JSON.stringify(bundle))); - return (assetFetch as unknown as (u: RequestInfo | URL) => Promise)(url); - }); - vi.stubGlobal('fetch', fetchMock); + it('passes the org as a query parameter for an org-scoped bundle (W1)', async () => { + const { calls } = stubFetch(() => blobResponse(ZIP_BYTES)); - const resolveVersion = vi.fn(() => '2.3.4'); - const { blob } = await downloadBundle('tiny', { - resolveVersion, - retry: fastRetry, + const { filename } = await downloadBundle('qa-bundle', { + client, + org: 'cupay', triggerDownload: vi.fn(), - version: '0.1.0', + version: '1.0.0', }); - expect(resolveVersion).toHaveBeenCalledTimes(1); - const entries = await readZipEntries(blob); - expect(Object.keys(entries)).toContain('clarification-agent/manifest.json'); - }); - - it('throws when a member has no version and the resolver returns undefined', async () => { - const bundle: Bundle = { - assets: [{ name: 'unknown', type: 'agent' }], - author: 'x', - description: 'b', - name: 'broken', - version: '0.1.0', - }; - const bundleUrl = buildBundleUrl('broken', '0.1.0'); - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - if (String(url) === bundleUrl) return okResponse(encodeBase64Text(JSON.stringify(bundle))); - return new Response('', { status: 404 }); + expect(filename).toBe('qa-bundle-1.0.0.zip'); + expect(parseUrl(calls[0]!.url)).toEqual({ + path: '/bundles/qa-bundle/1.0.0/download', + query: { format: 'zip', org: 'cupay' }, }); - vi.stubGlobal('fetch', fetchMock); - - await expect( - downloadBundle('broken', { retry: fastRetry, triggerDownload: vi.fn(), version: '0.1.0' }), - ).rejects.toThrow(/missing a version/); }); - it('surfaces RegistryNotFoundError when the bundle manifest itself is missing', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 404 })); - vi.stubGlobal('fetch', fetchMock); + it('surfaces RegistryNotFoundError when the bundle is missing', async () => { + stubFetch(() => apiErrorResponse(404, 'not_found', 'Unknown bundle')); await expect( - downloadBundle('missing-bundle', { retry: fastRetry, triggerDownload: vi.fn(), version: '1.0.0' }), + downloadBundle('missing-bundle', { client, triggerDownload: vi.fn(), version: '1.0.0' }), ).rejects.toBeInstanceOf(RegistryNotFoundError); }); - it('fetches an org-scoped bundle manifest from its @org path (W1)', async () => { - const bundle: Bundle = { - assets: [{ name: 'clarification-agent', type: 'agent', version: '1.0.0' }], - author: 'cupay', - description: 'qa bundle', - name: 'qa-bundle', - org: 'cupay', - version: '1.0.0', - }; - const manifest: Manifest = { - author: 'community', - description: 'clarifier', - entrypoint: 'AGENT.md', - files: ['AGENT.md'], - name: 'clarification-agent', - type: 'agent', - version: '1.0.0', - }; - - const assetFetch = setupFetchForAssets([{ files: { 'AGENT.md': 'body' }, manifest }]); - const bundleUrl = buildBundleUrl('qa-bundle', '1.0.0', 'cupay'); - const seen: string[] = []; - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - seen.push(String(url)); - if (String(url) === bundleUrl) return okResponse(encodeBase64Text(JSON.stringify(bundle, null, 2))); - return (assetFetch as unknown as (u: RequestInfo | URL) => Promise)(url); - }); - vi.stubGlobal('fetch', fetchMock); - - const { filename } = await downloadBundle('qa-bundle', { - org: 'cupay', - retry: fastRetry, - triggerDownload: vi.fn(), - version: '1.0.0', - }); - - expect(filename).toBe('qa-bundle-1.0.0.zip'); - expect(seen).toContain(bundleUrl); - }); - - it('reports an org-scoped member that resolves to no asset as not-found-in-org (W4)', async () => { - const bundle: Bundle = { - assets: [{ name: 'login-helper', org: 'cupay', type: 'skill' }], - author: 'cupay', - description: 'b', - name: 'qa-bundle', - org: 'cupay', - version: '1.0.0', - }; - const bundleUrl = buildBundleUrl('qa-bundle', '1.0.0', 'cupay'); - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - if (String(url) === bundleUrl) return okResponse(encodeBase64Text(JSON.stringify(bundle))); - return new Response('', { status: 404 }); - }); - vi.stubGlobal('fetch', fetchMock); + it('surfaces the API message for a member that cannot be resolved (W4)', async () => { + stubFetch(() => apiErrorResponse(404, 'not_found', "Bundle member 'login-helper' not found in org 'cupay'")); await expect( - downloadBundle('qa-bundle', { - org: 'cupay', - // Resolver finds no matching asset in scope → undefined version. - resolveVersion: () => undefined, - retry: fastRetry, - triggerDownload: vi.fn(), - version: '1.0.0', - }), - ).rejects.toThrow(/not found in org 'cupay'/); + downloadBundle('qa-bundle', { client, org: 'cupay', triggerDownload: vi.fn(), version: '1.0.0' }), + ).rejects.toThrow(/not found/); }); }); diff --git a/src/__tests__/lib/publish-service.test.ts b/src/__tests__/lib/publish-service.test.ts index fbf3d3a..0442919 100644 --- a/src/__tests__/lib/publish-service.test.ts +++ b/src/__tests__/lib/publish-service.test.ts @@ -1,37 +1,37 @@ /* eslint-disable perfectionist/sort-modules */ -import type { Octokit } from '@octokit/rest'; - import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PublishRequest } from '@/lib/api/types.gen'; import type { Bundle } from '@/lib/schemas/bundle'; import type { Manifest } from '@/lib/schemas/manifest'; import { PublishBranchCollisionError, + PublishError, PublishNetworkError, PublishPermissionError, PublishRateLimitError, + PublishValidationError, + PublishVersionConflictError, } from '@/lib/publish-errors'; -import { DRY_RUN_PR_URL_MARKER, publishBundle, publishContribution } from '@/lib/publish-service'; - -const fastRetry = { baseDelayMs: 1, jitter: false, maxDelayMs: 5, maxRetries: 0 } as const; - -type Handler = (input: unknown) => Promise | unknown; +import { + buildAssetPublishRequest, + DRY_RUN_PR_URL_MARKER, + expectedBranchName, + publishBundle, + publishContribution, +} from '@/lib/publish-service'; -interface FakeOctokitResult { - octokit: Octokit; - spies: Record>; -} +import { + API_BASE, + apiErrorResponse, + jsonResponse, + makeTestApiClient, + stubFetch, + textResponse, +} from '../utils/api-stub'; -interface OctokitQueues { - createBlob?: Handler[]; - createCommit?: Handler[]; - createRef?: Handler[]; - createTree?: Handler[]; - getRef?: Handler[]; - pullsCreate?: Handler[]; - reposGet?: Handler[]; -} +const client = makeTestApiClient('tok'); function baseFiles() { return [{ content: '# Skill body', path: 'skill.md' }]; @@ -50,359 +50,320 @@ function baseManifest(): Manifest { } as Manifest; } -function httpError(status: number, message = `HTTP ${status}`, extra: Record = {}): Error & { - status: number; -} { - const err = new Error(message) as Error & { status: number }; - err.status = status; - Object.assign(err, extra); - return err; -} - -function makeFakeOctokit(queues: OctokitQueues): FakeOctokitResult { - const spies = { - createBlob: queue(queues.createBlob), - createCommit: queue(queues.createCommit), - createRef: queue(queues.createRef), - createTree: queue(queues.createTree), - getRef: queue(queues.getRef), - pullsCreate: queue(queues.pullsCreate), - reposGet: queue(queues.reposGet), - } as Record>; - - const octokit = { - rest: { - git: { - createBlob: spies.createBlob, - createCommit: spies.createCommit, - createRef: spies.createRef, - createTree: spies.createTree, - getRef: spies.getRef, - }, - pulls: { create: spies.pullsCreate }, - repos: { - get: spies.reposGet, - }, +function publishedResponse(overrides: Record = {}) { + return jsonResponse( + { + branchName: 'asset/skill/my-skill/1.0.0', + commitSha: 'abc123', + prNumber: 42, + prUrl: 'https://github.com/EmergentSoftware/agentic-toolkit-registry/pull/42', + reviewers: ['jasonpaff'], + warnings: [], + ...overrides, }, - } as unknown as Octokit; - - return { octokit, spies }; + 201, + ); } -function queue(items: Handler[] | undefined): ReturnType { - const pending = items ?? []; - return vi.fn(async (args: unknown) => { - const next = pending.shift(); - if (!next) throw new Error('fakeOctokit queue exhausted'); - const result = await next(args); - return result; +function planResponse(overrides: Record = {}) { + return jsonResponse({ + assetType: 'skill', + branchName: 'asset/skill/my-skill/1.0.0', + files: ['manifest.json', 'skill.md'], + isUpdate: false, + kind: 'asset', + name: 'my-skill', + prBody: '## New Asset: my-skill', + prTitle: 'feat(registry): add skill my-skill@1.0.0', + registryPath: 'assets/skills/my-skill/1.0.0/', + reviewers: ['jasonpaff'], + version: '1.0.0', + warnings: ['No README.md'], + ...overrides, }); } -function happyPathQueues(): OctokitQueues { - return { - createBlob: [ - () => ({ data: { sha: 'blob-1' } }), - () => ({ data: { sha: 'blob-2' } }), - () => ({ data: { sha: 'blob-3' } }), - ], - createCommit: [() => ({ data: { sha: 'commit-sha' } })], - createRef: [() => ({ data: {} })], - createTree: [() => ({ data: { sha: 'tree-sha' } })], - getRef: [ - // upstream HEAD - () => ({ data: { object: { sha: 'base-sha' } } }), - // collision check for branch (404 = available) - () => { - throw httpError(404, 'not found'); - }, - ], - pullsCreate: [ - () => ({ data: { html_url: 'https://github.com/EmergentSoftware/agentic-toolkit-registry/pull/42' } }), - ], - reposGet: [() => ({ data: { default_branch: 'main' } })], - }; +function bodyOf(index = 0): PublishRequest { + const call = calls()[index]; + if (!call) throw new Error(`no request #${index}`); + return JSON.parse(call.body) as PublishRequest; +} + +let recorded: ReturnType | undefined; +function calls() { + if (!recorded) throw new Error('fetch not stubbed'); + return recorded.calls; } afterEach(() => { + recorded = undefined; vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe('publishContribution', () => { - it('walks the full happy path and returns the created PR URL', async () => { - const { octokit, spies } = makeFakeOctokit(happyPathQueues()); + it('POSTs the payload to /publish and returns the created PR', async () => { + recorded = stubFetch(() => publishedResponse()); const progress: string[] = []; const result = await publishContribution({ + client, files: baseFiles(), manifest: baseManifest(), - octokit, onProgress: (event) => progress.push(event.step), readme: '# Hello', - retry: fastRetry, }); - expect(result.prUrl).toMatch(/pull\/42$/); - expect(result.dryRun).toBe(false); - expect(result.branchName).toBe('asset/skill/my-skill/1.0.0'); - expect(progress).toEqual(['preparing-workspace', 'uploading-files', 'opening-pull-request']); - - // 3 blobs: manifest.json + skill.md + README.md - expect(spies.createBlob).toHaveBeenCalledTimes(3); - expect(spies.createTree).toHaveBeenCalledTimes(1); - expect(spies.createCommit).toHaveBeenCalledTimes(1); - expect(spies.createRef).toHaveBeenCalledTimes(1); - expect(spies.pullsCreate).toHaveBeenCalledTimes(1); - - // Single reposGet on upstream — no fork lookup. - expect(spies.reposGet).toHaveBeenCalledTimes(1); - expect(spies.reposGet).toHaveBeenCalledWith( - expect.objectContaining({ owner: 'EmergentSoftware', repo: 'agentic-toolkit-registry' }), - ); - - // All Git Data API calls target the upstream owner directly. - expect(spies.createBlob).toHaveBeenCalledWith( - expect.objectContaining({ owner: 'EmergentSoftware', repo: 'agentic-toolkit-registry' }), - ); - expect(spies.createRef).toHaveBeenCalledWith( - expect.objectContaining({ owner: 'EmergentSoftware', repo: 'agentic-toolkit-registry' }), - ); - - expect(spies.pullsCreate).toHaveBeenCalledWith( - expect.objectContaining({ - base: 'main', - head: 'asset/skill/my-skill/1.0.0', - owner: 'EmergentSoftware', - repo: 'agentic-toolkit-registry', - title: 'feat(registry): add skill my-skill@1.0.0', - }), - ); + expect(result).toEqual({ + branchName: 'asset/skill/my-skill/1.0.0', + dryRun: false, + prNumber: 42, + prUrl: 'https://github.com/EmergentSoftware/agentic-toolkit-registry/pull/42', + reviewers: ['jasonpaff'], + warnings: [], + }); + expect(progress).toEqual(['preparing-workspace', 'opening-pull-request']); + + expect(calls()).toHaveLength(1); + expect(calls()[0]!.method).toBe('POST'); + expect(calls()[0]!.url).toBe(`${API_BASE}/publish`); + expect(calls()[0]!.headers.get('authorization')).toBe('Bearer tok'); + expect(calls()[0]!.headers.get('content-type')).toBe('application/json'); + + const body = bodyOf(); + expect(body.kind).toBe('asset'); + expect(body.client).toBe('web'); + expect(body.manifest).toEqual(baseManifest()); + // manifest.json comes from `manifest`; README is appended as a file. + expect(body.files).toEqual([ + { content: '# Skill body', encoding: 'utf8', path: 'skill.md' }, + { content: '# Hello\n', encoding: 'utf8', path: 'README.md' }, + ]); }); - it('throws PublishBranchCollisionError when the branch already exists', async () => { - const queues: OctokitQueues = { - ...happyPathQueues(), - getRef: [ - // upstream HEAD - () => ({ data: { object: { sha: 'base-sha' } } }), - // collision check — ref EXISTS - () => ({ data: { object: { sha: 'existing' } } }), - ], - }; - const { octokit } = makeFakeOctokit(queues); + it('omits README.md from files when the readme is blank', async () => { + recorded = stubFetch(() => publishedResponse()); - await expect( - publishContribution({ - files: baseFiles(), - manifest: baseManifest(), - octokit, - readme: '', - retry: fastRetry, - }), - ).rejects.toBeInstanceOf(PublishBranchCollisionError); - }); + await publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: ' ' }); - it('maps 429 responses to PublishRateLimitError', async () => { - const queues: OctokitQueues = { - reposGet: [ - () => { - throw httpError(429, 'too many requests'); - }, - ], - }; - const { octokit } = makeFakeOctokit(queues); - - await expect( - publishContribution({ - files: baseFiles(), - manifest: baseManifest(), - octokit, - readme: '', - retry: fastRetry, - }), - ).rejects.toBeInstanceOf(PublishRateLimitError); + expect(bodyOf().files?.map((f) => f.path)).toEqual(['skill.md']); }); - it('maps 403 responses to PublishPermissionError', async () => { - const queues: OctokitQueues = { - reposGet: [ - () => { - throw httpError(403, 'forbidden'); - }, - ], - }; - const { octokit } = makeFakeOctokit(queues); - - await expect( - publishContribution({ - files: baseFiles(), - manifest: baseManifest(), - octokit, - readme: '', - retry: fastRetry, - }), - ).rejects.toBeInstanceOf(PublishPermissionError); - }); + it('drops uploaded manifest.json / README.md in favour of the wizard values', async () => { + recorded = stubFetch(() => publishedResponse()); - it('maps transport failures to PublishNetworkError', async () => { - const queues: OctokitQueues = { - reposGet: [ - () => { - throw new TypeError('offline'); - }, + await publishContribution({ + client, + files: [ + { content: '{"stale":true}', path: 'manifest.json' }, + { content: '# stale readme', path: 'README.md' }, + { content: '# Skill body', path: 'SKILL.md' }, ], - }; - const { octokit } = makeFakeOctokit(queues); - - await expect( - publishContribution({ - files: baseFiles(), - manifest: baseManifest(), - octokit, - readme: '', - retry: fastRetry, - }), - ).rejects.toBeInstanceOf(PublishNetworkError); - }); - - it('dry-run mode skips pulls.create and returns the synthesized marker URL', async () => { - const queues = happyPathQueues(); - queues.pullsCreate = []; // must never be called - const { octokit, spies } = makeFakeOctokit(queues); - - const result = await publishContribution({ - dryRun: true, - files: baseFiles(), manifest: baseManifest(), - octokit, - readme: '', - retry: fastRetry, + readme: '# fresh', }); - expect(result.dryRun).toBe(true); - expect(result.prUrl).toBe(DRY_RUN_PR_URL_MARKER); - expect(spies.pullsCreate).not.toHaveBeenCalled(); - // The branch ref should still have been created so QA can verify the commit. - expect(spies.createRef).toHaveBeenCalledTimes(1); - }); - - it('prefixes committed files with the registry path including @org scope', async () => { - const queues = happyPathQueues(); - const treeSpy = vi.fn(() => ({ data: { sha: 'tree-sha' } })); - queues.createTree = [treeSpy as Handler]; - const { octokit } = makeFakeOctokit(queues); - - await publishContribution({ - files: baseFiles(), - manifest: { ...baseManifest(), org: 'acme' } as Manifest, - octokit, - readme: '', - retry: fastRetry, - }); - - expect(treeSpy).toHaveBeenCalledTimes(1); - const treeArgs = (treeSpy.mock.calls[0]! as unknown as [{ tree: Array<{ path: string }> }])[0]; - const paths = treeArgs.tree.map((entry) => entry.path); - expect(paths).toContain('assets/skills/@acme/my-skill/1.0.0/manifest.json'); - expect(paths).toContain('assets/skills/@acme/my-skill/1.0.0/skill.md'); + expect(bodyOf().files?.map((f) => f.path)).toEqual(['SKILL.md', 'README.md']); + expect(bodyOf().files?.find((f) => f.path === 'README.md')?.content).toBe('# fresh\n'); }); it('strips a common wrapper folder added by browser folder uploads', async () => { - const queues = happyPathQueues(); - const treeSpy = vi.fn(() => ({ data: { sha: 'tree-sha' } })); - queues.createTree = [treeSpy as Handler]; - const { octokit } = makeFakeOctokit(queues); + recorded = stubFetch(() => publishedResponse()); await publishContribution({ + client, files: [ { content: '# Skill body', path: 'emergent-brand/SKILL.md' }, { content: '# Guide', path: 'emergent-brand/references/guide.md' }, ], manifest: { ...baseManifest(), name: 'emergent-brand-skill' } as Manifest, - octokit, readme: '', - retry: fastRetry, }); - const treeArgs = (treeSpy.mock.calls[0]! as unknown as [{ tree: Array<{ path: string }> }])[0]; - const paths = treeArgs.tree.map((entry) => entry.path); - expect(paths).toContain('assets/skills/emergent-brand-skill/1.0.0/SKILL.md'); - expect(paths).toContain('assets/skills/emergent-brand-skill/1.0.0/references/guide.md'); - for (const p of paths) { - expect(p).not.toContain('/emergent-brand/'); - } + const paths = bodyOf().files?.map((f) => f.path); + expect(paths).toEqual(['SKILL.md', 'references/guide.md']); }); - it('leaves flat file drops unchanged', async () => { - const queues = happyPathQueues(); - const treeSpy = vi.fn(() => ({ data: { sha: 'tree-sha' } })); - queues.createTree = [treeSpy as Handler]; - const { octokit } = makeFakeOctokit(queues); + it('leaves flat file drops and divergent top-level directories unchanged', async () => { + recorded = stubFetch(() => publishedResponse()); await publishContribution({ - files: [{ content: '# Skill body', path: 'SKILL.md' }], + client, + files: [ + { content: 'x', path: 'a/x.md' }, + { content: 'y', path: 'b/y.md' }, + ], manifest: baseManifest(), - octokit, readme: '', - retry: fastRetry, }); - const treeArgs = (treeSpy.mock.calls[0]! as unknown as [{ tree: Array<{ path: string }> }])[0]; - const paths = treeArgs.tree.map((entry) => entry.path); - expect(paths).toContain('assets/skills/my-skill/1.0.0/SKILL.md'); - expect(paths).toContain('assets/skills/my-skill/1.0.0/manifest.json'); + expect(bodyOf().files?.map((f) => f.path)).toEqual(['a/x.md', 'b/y.md']); }); - it('uploads base64 entries verbatim and utf8 entries re-encoded', async () => { - const queues = happyPathQueues(); - const blobSpy = vi.fn((args: unknown) => { - const { content } = args as { content: string }; - return { data: { sha: `blob-${content.slice(0, 6)}` } }; - }); - // manifest.json + SKILL.md + assets/logo.png = 3 blobs. - queues.createBlob = [blobSpy as Handler, blobSpy as Handler, blobSpy as Handler]; - const { octokit } = makeFakeOctokit(queues); - - const pngBase64 = 'iVBORw0KGgo='; // arbitrary base64 payload + it('sends base64 entries verbatim with their encoding and utf8 entries as text', async () => { + recorded = stubFetch(() => publishedResponse()); + const pngBase64 = 'iVBORw0KGgo='; await publishContribution({ + client, files: [ { content: '# Skill body', encoding: 'utf8', path: 'SKILL.md' }, { content: pngBase64, encoding: 'base64', path: 'assets/logo.png' }, ], manifest: baseManifest(), - octokit, readme: '', - retry: fastRetry, }); - const contents = blobSpy.mock.calls.map((call) => (call[0] as { content: string }).content); - // Binary entry is passed through unchanged; text entry is base64-encoded. - expect(contents).toContain(pngBase64); - expect(contents).not.toContain('# Skill body'); + expect(bodyOf().files).toEqual([ + { content: '# Skill body', encoding: 'utf8', path: 'SKILL.md' }, + { content: pngBase64, encoding: 'base64', path: 'assets/logo.png' }, + ]); }); - it('preserves divergent top-level directories without stripping', async () => { - const queues = happyPathQueues(); - const treeSpy = vi.fn(() => ({ data: { sha: 'tree-sha' } })); - queues.createTree = [treeSpy as Handler]; - const { octokit } = makeFakeOctokit(queues); - - await publishContribution({ + it('expands a files: "auto" manifest into the concrete uploaded list', () => { + const request = buildAssetPublishRequest({ files: [ - { content: 'x', path: 'a/x.md' }, - { content: 'y', path: 'b/y.md' }, + { content: 'a', path: 'skill.md' }, + { content: 'b', path: 'reference/notes.md' }, ], + manifest: { ...baseManifest(), files: 'auto' } as Manifest, + readme: '', + }); + + expect((request.manifest as { files: unknown }).files).toEqual(['reference/notes.md']); + }); + + it('keeps the org inside the manifest and predicts the org-scoped branch', () => { + const request = buildAssetPublishRequest({ + files: baseFiles(), + manifest: { ...baseManifest(), org: 'acme' } as Manifest, + readme: '', + }); + + expect((request.manifest as { org?: string }).org).toBe('acme'); + expect(expectedBranchName(request)).toBe('asset/skill/acme/my-skill/1.0.0'); + }); + + it('dry-run mode POSTs to /publish/plan and returns the synthesized marker URL', async () => { + recorded = stubFetch(() => planResponse()); + const progress: string[] = []; + + const result = await publishContribution({ + client, + dryRun: true, + files: baseFiles(), manifest: baseManifest(), - octokit, + onProgress: (event) => progress.push(event.step), readme: '', - retry: fastRetry, }); - const treeArgs = (treeSpy.mock.calls[0]! as unknown as [{ tree: Array<{ path: string }> }])[0]; - const paths = treeArgs.tree.map((entry) => entry.path); - expect(paths).toContain('assets/skills/my-skill/1.0.0/a/x.md'); - expect(paths).toContain('assets/skills/my-skill/1.0.0/b/y.md'); + expect(result).toEqual({ + branchName: 'asset/skill/my-skill/1.0.0', + dryRun: true, + prUrl: DRY_RUN_PR_URL_MARKER, + reviewers: ['jasonpaff'], + warnings: ['No README.md'], + }); + expect(progress).toEqual(['preparing-workspace', 'uploading-files']); + expect(calls()).toHaveLength(1); + expect(calls()[0]!.url).toBe(`${API_BASE}/publish/plan`); + expect(bodyOf().kind).toBe('asset'); + }); + + it('maps 409 branch_exists to PublishBranchCollisionError naming the branch', async () => { + recorded = stubFetch(() => apiErrorResponse(409, 'branch_exists', 'Branch already exists')); + + const error = await publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }).catch( + (e) => e, + ); + + expect(error).toBeInstanceOf(PublishBranchCollisionError); + expect((error as PublishBranchCollisionError).branchName).toBe('asset/skill/my-skill/1.0.0'); + }); + + it('maps 409 version_not_bumped / version_exists to PublishVersionConflictError with the API message', async () => { + recorded = stubFetch(() => apiErrorResponse(409, 'version_not_bumped', '1.0.0 is not newer than 1.2.0')); + + const error = await publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }).catch( + (e) => e, + ); + + expect(error).toBeInstanceOf(PublishVersionConflictError); + expect((error as PublishVersionConflictError).code).toBe('version_not_bumped'); + expect((error as PublishError).userMessage).toBe('1.0.0 is not newer than 1.2.0'); + }); + + it('maps 400 validation_failed / schema_invalid to PublishValidationError carrying details', async () => { + const details = [ + { message: 'must match pattern ^[a-z]', path: '/manifest/name' }, + { message: 'entrypoint skill.md is not in files', path: null }, + ]; + recorded = stubFetch(() => apiErrorResponse(400, 'validation_failed', 'Validation failed', details)); + + const error = await publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }).catch( + (e) => e, + ); + + expect(error).toBeInstanceOf(PublishValidationError); + expect((error as PublishValidationError).details).toEqual(details); + expect((error as PublishError).userMessage).toBe('Validation failed'); + }); + + it('maps 401 and non-member 403 to PublishPermissionError', async () => { + recorded = stubFetch(() => apiErrorResponse(401, 'unauthorized', 'Bad token')); + await expect( + publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }), + ).rejects.toBeInstanceOf(PublishPermissionError); + + recorded = stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not a member')); + await expect( + publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }), + ).rejects.toBeInstanceOf(PublishPermissionError); + }); + + it('surfaces other 400s (e.g. reviewers_not_allowed) as a PublishError with the API message', async () => { + recorded = stubFetch(() => apiErrorResponse(400, 'reviewers_not_allowed', 'Global targets cannot set reviewers')); + + const error = await publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }).catch( + (e) => e, + ); + + expect(error).toBeInstanceOf(PublishError); + expect(error).not.toBeInstanceOf(PublishValidationError); + expect((error as PublishError).userMessage).toBe('Global targets cannot set reviewers'); + }); + + it('maps 429 responses to PublishRateLimitError (after retries)', async () => { + recorded = stubFetch(() => apiErrorResponse(429, 'rate_limited', 'Slow down')); + + await expect( + publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }), + ).rejects.toBeInstanceOf(PublishRateLimitError); + expect(calls().length).toBeGreaterThan(1); + }); + + it('maps transport failures and 5xx responses to PublishNetworkError', async () => { + recorded = stubFetch(() => { + throw new TypeError('offline'); + }); + await expect( + publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }), + ).rejects.toBeInstanceOf(PublishNetworkError); + + recorded = stubFetch(() => textResponse('Bad Gateway', 502, 'text/plain')); + const error = await publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }).catch( + (e) => e, + ); + expect(error).toBeInstanceOf(PublishNetworkError); + expect((error as PublishNetworkError).status).toBe(502); + }); + + it('passes the reviewer warning through when the PR opened but reviewers failed', async () => { + recorded = stubFetch(() => publishedResponse({ reviewers: [], reviewerWarning: 'Could not request reviewers' })); + + const result = await publishContribution({ client, files: baseFiles(), manifest: baseManifest(), readme: '' }); + + expect(result.reviewerWarning).toBe('Could not request reviewers'); }); }); @@ -421,85 +382,59 @@ function baseBundle(): Bundle { } describe('publishBundle', () => { - it('walks the full happy path and writes bundle.json under the versioned path', async () => { - const queues = happyPathQueues(); - const treeSpy = vi.fn(() => ({ data: { sha: 'tree-sha' } })); - queues.createTree = [treeSpy as Handler]; - // bundle.json + README.md = 2 blobs - queues.createBlob = [() => ({ data: { sha: 'blob-1' } }), () => ({ data: { sha: 'blob-2' } })]; - const { octokit, spies } = makeFakeOctokit(queues); - - const result = await publishBundle({ - bundle: baseBundle(), - octokit, - readme: '# Feature workflow', - retry: fastRetry, - }); + it('POSTs bundle.json as the manifest with kind=bundle and client=web', async () => { + recorded = stubFetch(() => + publishedResponse({ + branchName: 'bundle/feature-workflow/1.0.0', + prUrl: 'https://github.com/EmergentSoftware/agentic-toolkit-registry/pull/7', + }), + ); + + const result = await publishBundle({ bundle: baseBundle(), client, readme: '# Feature workflow' }); expect(result.dryRun).toBe(false); expect(result.branchName).toBe('bundle/feature-workflow/1.0.0'); - expect(spies.createBlob).toHaveBeenCalledTimes(2); - - const treeArgs = (treeSpy.mock.calls[0]! as unknown as [{ tree: Array<{ path: string }> }])[0]; - const paths = treeArgs.tree.map((entry) => entry.path); - expect(paths).toContain('bundles/feature-workflow/1.0.0/bundle.json'); - expect(paths).toContain('bundles/feature-workflow/1.0.0/README.md'); + expect(result.prUrl).toMatch(/pull\/7$/); - expect(spies.pullsCreate).toHaveBeenCalledWith( - expect.objectContaining({ - head: 'bundle/feature-workflow/1.0.0', - title: 'feat(registry): add bundle feature-workflow@1.0.0', - }), - ); + const body = bodyOf(); + expect(body.kind).toBe('bundle'); + expect(body.client).toBe('web'); + expect(body.manifest).toEqual(baseBundle()); + expect(body.files).toEqual([{ content: '# Feature workflow\n', encoding: 'utf8', path: 'README.md' }]); }); - it('omits README.md when the readme is blank (bundle.json only)', async () => { - const queues = happyPathQueues(); - const treeSpy = vi.fn(() => ({ data: { sha: 'tree-sha' } })); - queues.createTree = [treeSpy as Handler]; - queues.createBlob = [() => ({ data: { sha: 'blob-1' } })]; - const { octokit, spies } = makeFakeOctokit(queues); + it('sends no files when the readme is blank', async () => { + recorded = stubFetch(() => publishedResponse({ branchName: 'bundle/feature-workflow/1.0.0' })); - await publishBundle({ bundle: baseBundle(), octokit, readme: '', retry: fastRetry }); + await publishBundle({ bundle: baseBundle(), client, readme: '' }); - expect(spies.createBlob).toHaveBeenCalledTimes(1); - const treeArgs = (treeSpy.mock.calls[0]! as unknown as [{ tree: Array<{ path: string }> }])[0]; - const paths = treeArgs.tree.map((entry) => entry.path); - expect(paths).toEqual(['bundles/feature-workflow/1.0.0/bundle.json']); + expect(bodyOf().files).toEqual([]); }); - it('throws PublishBranchCollisionError when the bundle branch already exists', async () => { - const queues: OctokitQueues = { - ...happyPathQueues(), - getRef: [ - () => ({ data: { object: { sha: 'base-sha' } } }), - () => ({ data: { object: { sha: 'existing' } } }), - ], - }; - const { octokit } = makeFakeOctokit(queues); + it('keeps the org in bundle.json for org-scoped bundles and predicts the org branch on collision', async () => { + recorded = stubFetch(() => apiErrorResponse(409, 'branch_exists', 'exists')); - await expect( - publishBundle({ bundle: baseBundle(), octokit, readme: '', retry: fastRetry }), - ).rejects.toBeInstanceOf(PublishBranchCollisionError); + const error = await publishBundle({ + bundle: { ...baseBundle(), name: 'qa-bundle', org: 'cupay' }, + client, + readme: '', + }).catch((e) => e); + + expect((bodyOf().manifest as { org?: string }).org).toBe('cupay'); + expect(error).toBeInstanceOf(PublishBranchCollisionError); + expect((error as PublishBranchCollisionError).branchName).toBe('bundle/cupay/qa-bundle/1.0.0'); }); - it('dry-run mode skips pulls.create and returns the synthesized marker URL', async () => { - const queues = happyPathQueues(); - queues.createBlob = [() => ({ data: { sha: 'blob-1' } })]; - queues.pullsCreate = []; - const { octokit, spies } = makeFakeOctokit(queues); + it('dry-run mode POSTs to /publish/plan and returns the synthesized marker URL', async () => { + recorded = stubFetch(() => + planResponse({ assetType: null, branchName: 'bundle/feature-workflow/1.0.0', kind: 'bundle', warnings: [] }), + ); - const result = await publishBundle({ - bundle: baseBundle(), - dryRun: true, - octokit, - readme: '', - retry: fastRetry, - }); + const result = await publishBundle({ bundle: baseBundle(), client, dryRun: true, readme: '' }); expect(result.dryRun).toBe(true); expect(result.prUrl).toBe(DRY_RUN_PR_URL_MARKER); - expect(spies.pullsCreate).not.toHaveBeenCalled(); - expect(spies.createRef).toHaveBeenCalledTimes(1); + expect(result.branchName).toBe('bundle/feature-workflow/1.0.0'); + expect(calls()[0]!.url).toBe(`${API_BASE}/publish/plan`); }); }); diff --git a/src/__tests__/lib/query-hooks.test.tsx b/src/__tests__/lib/query-hooks.test.tsx index 608b859..712c65d 100644 --- a/src/__tests__/lib/query-hooks.test.tsx +++ b/src/__tests__/lib/query-hooks.test.tsx @@ -1,44 +1,28 @@ -import type { Octokit } from '@octokit/rest'; import type { ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ApiClient } from '@/lib/api-client'; + +import { useAssetFiles } from '@/hooks/useAssetFiles'; import { useRegistry } from '@/hooks/useRegistry'; import { queryKeys } from '@/lib/query-keys'; import { loadFixtureRegistry } from '../fixtures'; +import { apiErrorResponse, jsonResponse, makeTestApiClient, stubFetch } from '../utils/api-stub'; -// Intercept the session so we can feed the hooks a controlled Octokit (or null). +// Intercept the session so we can feed the hooks a controlled API client (or null). const sessionValueMock: { - octokit: null | Octokit; + api: ApiClient | null; token: null | string; -} = { octokit: null, token: null }; +} = { api: null, token: null }; vi.mock('@/hooks/useSession', () => ({ useSession: () => sessionValueMock, })); -type GetContentResult = Awaited>; - -function fakeOctokit(queue: Array): Octokit { - const spy = vi.fn(async () => { - const next = queue.shift(); - if (next === undefined) throw new Error('fakeOctokit: queue exhausted'); - if (next instanceof Error) throw next; - if (typeof next === 'string') return rawResponse(next); - return next; - }); - return { rest: { repos: { getContent: spy } } } as unknown as Octokit; -} - -function httpError(status: number): Error & { status: number } { - const err = new Error(`HTTP ${status}`) as Error & { status: number }; - err.status = status; - return err; -} - function makeWrapper() { const client = new QueryClient({ defaultOptions: { @@ -51,21 +35,18 @@ function makeWrapper() { return { client, Wrapper }; } -function rawResponse(raw: string): GetContentResult { - return { data: raw } as unknown as GetContentResult; -} - describe('useRegistry (TanStack Query)', () => { beforeEach(() => { - sessionValueMock.octokit = null; + sessionValueMock.api = null; sessionValueMock.token = null; }); afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it('stays disabled when no Octokit is available on the session', async () => { + it('stays disabled when no API client is available on the session', async () => { const { Wrapper } = makeWrapper(); const { result } = renderHook(() => useRegistry(), { wrapper: Wrapper }); @@ -74,13 +55,11 @@ describe('useRegistry (TanStack Query)', () => { expect(result.current.data).toBeUndefined(); }); - it('fetches and caches registry data via Octokit (no refetch on rerender)', async () => { + it('fetches and caches registry data via the API (no refetch on rerender)', async () => { const fixture = loadFixtureRegistry(); sessionValueMock.token = 'tok'; - sessionValueMock.octokit = fakeOctokit([ - JSON.stringify(fixture), - JSON.stringify(fixture), - ]); + sessionValueMock.api = makeTestApiClient('tok'); + const { calls } = stubFetch(() => jsonResponse(fixture)); const { Wrapper } = makeWrapper(); const { rerender, result } = renderHook(() => useRegistry(), { wrapper: Wrapper }); @@ -88,35 +67,33 @@ describe('useRegistry (TanStack Query)', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.assets).toHaveLength(fixture.assets.length); - const spy = sessionValueMock.octokit.rest.repos.getContent as unknown as ReturnType; - expect(spy).toHaveBeenCalledTimes(1); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('http://localhost:7071/registry'); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer tok'); rerender(); - expect(spy).toHaveBeenCalledTimes(1); + expect(calls).toHaveLength(1); }); it('refetches when the query is invalidated', async () => { const fixture = loadFixtureRegistry(); sessionValueMock.token = 'tok'; - sessionValueMock.octokit = fakeOctokit([ - JSON.stringify(fixture), - JSON.stringify(fixture), - ]); + sessionValueMock.api = makeTestApiClient('tok'); + const { calls } = stubFetch(() => jsonResponse(fixture)); const { client, Wrapper } = makeWrapper(); const { result } = renderHook(() => useRegistry(), { wrapper: Wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - const spy = sessionValueMock.octokit.rest.repos.getContent as unknown as ReturnType; - expect(spy).toHaveBeenCalledTimes(1); + expect(calls).toHaveLength(1); await client.invalidateQueries({ queryKey: queryKeys.registry() }); - await waitFor(() => expect(spy).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(calls).toHaveLength(2)); }); it('surfaces 404s through useQuery.error as RegistryNotFoundError', async () => { sessionValueMock.token = 'tok'; - sessionValueMock.octokit = fakeOctokit([httpError(404)]); + sessionValueMock.api = makeTestApiClient('tok'); + stubFetch(() => apiErrorResponse(404, 'not_found', 'registry.json is missing')); const { Wrapper } = makeWrapper(); const { result } = renderHook(() => useRegistry(), { wrapper: Wrapper }); @@ -125,3 +102,50 @@ describe('useRegistry (TanStack Query)', () => { expect(result.current.error?.name).toBe('RegistryNotFoundError'); }); }); + +describe('useAssetFiles (TanStack Query)', () => { + beforeEach(() => { + sessionValueMock.api = null; + sessionValueMock.token = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('stays disabled until the ref is complete and a client is available', async () => { + sessionValueMock.api = makeTestApiClient('tok'); + const { calls } = stubFetch(() => jsonResponse({})); + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useAssetFiles({ name: 'validate', type: 'agent' }), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.fetchStatus).toBe('idle')); + expect(calls).toHaveLength(0); + }); + + it('fetches the file listing under the assetFiles key', async () => { + sessionValueMock.token = 'tok'; + sessionValueMock.api = makeTestApiClient('tok'); + const listing = { + files: [ + { path: 'AGENT.md', sha: 'a', size: 10 }, + { path: 'manifest.json', sha: 'b', size: 20 }, + ], + name: 'validate', + org: 'agentic-toolkit', + type: 'agent', + version: '1.1.0', + }; + const { calls } = stubFetch(() => jsonResponse(listing)); + + const { client, Wrapper } = makeWrapper(); + const ref = { name: 'validate', org: 'agentic-toolkit', type: 'agent' as const, version: '1.1.0' }; + const { result } = renderHook(() => useAssetFiles(ref), { wrapper: Wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.files.map((f) => f.path)).toEqual(['AGENT.md', 'manifest.json']); + expect(calls[0]!.url).toBe('http://localhost:7071/assets/agent/validate/1.1.0/files?org=agentic-toolkit'); + expect(client.getQueryData(queryKeys.assetFiles(ref))).toBeDefined(); + }); +}); diff --git a/src/__tests__/lib/registry-client.test.ts b/src/__tests__/lib/registry-client.test.ts index aae5aa3..602ad71 100644 --- a/src/__tests__/lib/registry-client.test.ts +++ b/src/__tests__/lib/registry-client.test.ts @@ -1,8 +1,7 @@ -import type { Octokit } from '@octokit/rest'; - import { afterEach, describe, expect, it, vi } from 'vitest'; import { + fetchAssetFiles, fetchAssetManifest, fetchAssetReadme, fetchBundleManifest, @@ -13,136 +12,90 @@ import { import { RegistryFetchError, RegistryNotFoundError, RegistryParseError } from '@/lib/registry-errors'; import { loadFixtureRegistry } from '../fixtures'; +import { + API_BASE, + apiErrorResponse, + jsonResponse, + makeTestApiClient, + stubFetch, + textResponse, +} from '../utils/api-stub'; -const fastRetry = { baseDelayMs: 1, jitter: false, maxDelayMs: 5, maxRetries: 1 } as const; - -type GetContentResult = Awaited>; - -function httpError(status: number, message = `HTTP ${status}`): Error & { status: number } { - const err = new Error(message) as Error & { status: number }; - err.status = status; - return err; -} - -/** - * Build a minimal fake Octokit whose `rest.repos.getContent` resolves from the - * given queue. The queue is consumed FIFO; a value can be a raw string, an - * Error to throw, or a ready-made response envelope. - */ -function makeFakeOctokit(queue: Array): { - octokit: Octokit; - spy: ReturnType; -} { - const spy = vi.fn(async () => { - const next = queue.shift(); - if (next === undefined) throw new Error('fakeOctokit: queue exhausted'); - if (next instanceof Error) throw next; - if (typeof next === 'string') return rawResponse(next); - return next; - }); - const octokit = { rest: { repos: { getContent: spy } } } as unknown as Octokit; - return { octokit, spy }; -} - -function rawResponse(raw: string): GetContentResult { - return { data: raw } as unknown as GetContentResult; -} +const client = makeTestApiClient('tok'); -describe('registry-client (Octokit-backed)', () => { +describe('registry-client (ATK API-backed)', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); describe('fetchRegistry', () => { - it('fetches, parses, and validates the registry via octokit.rest.repos.getContent', async () => { + it('fetches, parses, and validates the registry via GET /registry with the bearer token', async () => { const fixture = loadFixtureRegistry(); - const { octokit, spy } = makeFakeOctokit([JSON.stringify(fixture)]); + const { calls } = stubFetch(() => jsonResponse(fixture)); - const result = await fetchRegistry({ octokit, retry: fastRetry }); + const result = await fetchRegistry({ client }); expect(result.assets).toHaveLength(fixture.assets.length); expect(result.bundles?.[0]?.name).toBe('feature-workflow'); expect(result.deprecated?.[0]?.name).toBe('old-validate'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - mediaType: { format: 'raw' }, - owner: 'EmergentSoftware', - path: 'registry.json', - repo: 'agentic-toolkit-registry', - }), - ); - }); - - it('applies owner/repo/ref overrides', async () => { - const { octokit, spy } = makeFakeOctokit([JSON.stringify(loadFixtureRegistry())]); - - await fetchRegistry({ octokit, owner: 'acme', ref: 'main', repo: 'registry', retry: fastRetry }); - - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ owner: 'acme', ref: 'main', repo: 'registry' }), - ); - }); - - it('throws RegistryParseError on malformed JSON', async () => { - const { octokit } = makeFakeOctokit(['{not json']); - - await expect(fetchRegistry({ octokit, retry: fastRetry })).rejects.toMatchObject({ - constructor: RegistryParseError, - message: expect.stringContaining('not valid JSON'), - }); + expect(calls).toHaveLength(1); + expect(calls[0]!.method).toBe('GET'); + expect(calls[0]!.url).toBe(`${API_BASE}/registry`); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer tok'); }); it('throws RegistryParseError when schema validation fails', async () => { - const { octokit } = makeFakeOctokit([JSON.stringify({ not: 'a registry' })]); + stubFetch(() => jsonResponse({ not: 'a registry' })); - const error = await fetchRegistry({ octokit, retry: fastRetry }).catch((e) => e); + const error = await fetchRegistry({ client }).catch((e) => e); expect(error).toBeInstanceOf(RegistryParseError); expect((error as RegistryParseError).zodError).toBeDefined(); expect((error as RegistryParseError).message).toMatch(/schema validation/); }); it('throws RegistryNotFoundError on 404 without retry', async () => { - const { octokit, spy } = makeFakeOctokit([httpError(404, 'not found')]); + const { calls } = stubFetch(() => apiErrorResponse(404, 'not_found', 'registry.json is missing')); - await expect(fetchRegistry({ octokit, retry: fastRetry })).rejects.toBeInstanceOf(RegistryNotFoundError); - expect(spy).toHaveBeenCalledTimes(1); + await expect(fetchRegistry({ client })).rejects.toBeInstanceOf(RegistryNotFoundError); + expect(calls).toHaveLength(1); }); it('throws RegistryFetchError on transport failure (after exhausting retries)', async () => { - const { octokit } = makeFakeOctokit([ - new TypeError('offline'), - new TypeError('offline'), - ]); + const { calls } = stubFetch(() => { + throw new TypeError('offline'); + }); - const error = await fetchRegistry({ octokit, retry: fastRetry }).catch((e) => e); + const error = await fetchRegistry({ client }).catch((e) => e); expect(error).toBeInstanceOf(RegistryFetchError); - expect((error as RegistryFetchError).cause).toBeInstanceOf(TypeError); + expect((error as RegistryFetchError).status).toBeUndefined(); + expect((error as RegistryFetchError).message).toMatch(/could not reach the atk api/i); + expect(calls).toHaveLength(2); }); - it('throws RegistryFetchError on non-retryable HTTP failure', async () => { - const { octokit } = makeFakeOctokit([httpError(403, 'forbidden')]); + it('throws RegistryFetchError carrying the status on non-retryable HTTP failure', async () => { + stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not a member')); - const error = await fetchRegistry({ octokit, retry: fastRetry }).catch((e) => e); + const error = await fetchRegistry({ client }).catch((e) => e); expect(error).toBeInstanceOf(RegistryFetchError); expect((error as RegistryFetchError).status).toBe(403); + expect((error as RegistryFetchError).message).toMatch(/EmergentSoftware/); }); it('retries transient 503 responses', async () => { - const { octokit, spy } = makeFakeOctokit([ - httpError(503, 'unavailable'), - JSON.stringify(loadFixtureRegistry()), - ]); + const { calls } = stubFetch((_req, index) => + index === 0 ? textResponse('', 503, 'text/plain') : jsonResponse(loadFixtureRegistry()), + ); - const result = await fetchRegistry({ octokit, retry: fastRetry }); + const result = await fetchRegistry({ client }); expect(result.assets.length).toBeGreaterThan(0); - expect(spy).toHaveBeenCalledTimes(2); + expect(calls).toHaveLength(2); }); }); describe('fetchAssetManifest', () => { - const manifestJson = JSON.stringify({ + const manifest = { author: 'EmergentSoftware', description: 'd', entrypoint: 'AGENT.md', @@ -150,128 +103,141 @@ describe('registry-client (Octokit-backed)', () => { org: 'agentic-toolkit', type: 'agent', version: '1.1.0', - }); + }; - it('builds the correct contents path with an org scope', async () => { - const { octokit, spy } = makeFakeOctokit([manifestJson]); + it('requests the manifest endpoint with the org as a query parameter', async () => { + const { calls } = stubFetch(() => jsonResponse(manifest)); const result = await fetchAssetManifest( { name: 'validate', org: 'agentic-toolkit', type: 'agent', version: '1.1.0' }, - { octokit, retry: fastRetry }, + { client }, ); expect(result.name).toBe('validate'); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - path: 'assets/agents/@agentic-toolkit/validate/1.1.0/manifest.json', - }), - ); + expect(calls[0]!.url).toBe(`${API_BASE}/assets/agent/validate/1.1.0/manifest?org=agentic-toolkit`); }); - it('builds an unscoped path when org is omitted', async () => { - const manifest = JSON.stringify({ - author: 'community', - description: 'd', - entrypoint: 'AGENT.md', - name: 'clarification-agent', - type: 'agent', - version: '1.0.0', - }); - const { octokit, spy } = makeFakeOctokit([manifest]); + it('omits the org query when the asset is global', async () => { + const { calls } = stubFetch(() => jsonResponse({ ...manifest, name: 'clarification-agent', org: undefined })); - await fetchAssetManifest( - { name: 'clarification-agent', type: 'agent', version: '1.0.0' }, - { octokit, retry: fastRetry }, - ); + await fetchAssetManifest({ name: 'clarification-agent', type: 'agent', version: '1.0.0' }, { client }); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - path: 'assets/agents/clarification-agent/1.0.0/manifest.json', - }), - ); + expect(calls[0]!.url).toBe(`${API_BASE}/assets/agent/clarification-agent/1.0.0/manifest`); + }); + + it('throws RegistryParseError when the manifest fails the Zod guard', async () => { + stubFetch(() => jsonResponse({ name: 'broken' })); + + await expect( + fetchAssetManifest({ name: 'broken', type: 'skill', version: '1.0.0' }, { client }), + ).rejects.toBeInstanceOf(RegistryParseError); }); }); describe('fetchAssetReadme', () => { - it('fetches README.md alongside the manifest path', async () => { + it('fetches README markdown as text', async () => { const markdown = '# Hello\n\nBody.'; - const { octokit, spy } = makeFakeOctokit([markdown]); + const { calls } = stubFetch(() => textResponse(markdown)); const result = await fetchAssetReadme( { name: 'validate', org: 'agentic-toolkit', type: 'agent', version: '1.1.0' }, - { octokit, retry: fastRetry }, + { client }, ); expect(result).toBe(markdown); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - path: 'assets/agents/@agentic-toolkit/validate/1.1.0/README.md', - }), - ); + expect(calls[0]!.url).toBe(`${API_BASE}/assets/agent/validate/1.1.0/readme?org=agentic-toolkit`); }); it('returns null when the README is missing (HTTP 404)', async () => { - const { octokit } = makeFakeOctokit([httpError(404)]); + stubFetch(() => apiErrorResponse(404, 'not_found', 'No README')); - const result = await fetchAssetReadme( - { name: 'no-readme', type: 'skill', version: '1.0.0' }, - { octokit, retry: fastRetry }, - ); + const result = await fetchAssetReadme({ name: 'no-readme', type: 'skill', version: '1.0.0' }, { client }); expect(result).toBeNull(); }); it('propagates transport errors as RegistryFetchError', async () => { - const { octokit } = makeFakeOctokit([ - new TypeError('offline'), - new TypeError('offline'), - ]); + stubFetch(() => { + throw new TypeError('offline'); + }); - await expect( - fetchAssetReadme({ name: 'x', type: 'skill', version: '1.0.0' }, { octokit, retry: fastRetry }), - ).rejects.toBeInstanceOf(RegistryFetchError); + await expect(fetchAssetReadme({ name: 'x', type: 'skill', version: '1.0.0' }, { client })).rejects.toBeInstanceOf( + RegistryFetchError, + ); + }); + }); + + describe('fetchAssetFiles', () => { + it('returns the API directory listing (manifest and README included)', async () => { + const listing = { + files: [ + { path: 'AGENT.md', sha: 'a', size: 12 }, + { path: 'README.md', sha: 'b', size: 34 }, + { path: 'manifest.json', sha: 'c', size: 56 }, + { path: 'reference/guide.md', sha: 'd', size: 78 }, + ], + name: 'validate', + org: 'agentic-toolkit', + type: 'agent', + version: '1.1.0', + }; + const { calls } = stubFetch(() => jsonResponse(listing)); + + const result = await fetchAssetFiles( + { name: 'validate', org: 'agentic-toolkit', type: 'agent', version: '1.1.0' }, + { client }, + ); + + expect(result.files.map((f) => f.path)).toEqual(['AGENT.md', 'README.md', 'manifest.json', 'reference/guide.md']); + expect(result.org).toBe('agentic-toolkit'); + expect(calls[0]!.url).toBe(`${API_BASE}/assets/agent/validate/1.1.0/files?org=agentic-toolkit`); + }); + + it('normalises a null org to undefined', async () => { + stubFetch(() => jsonResponse({ files: [], name: 'x', org: null, type: 'skill', version: '1.0.0' })); + + const result = await fetchAssetFiles({ name: 'x', type: 'skill', version: '1.0.0' }, { client }); + expect(result.org).toBeUndefined(); + }); + + it('throws RegistryNotFoundError for an unknown version', async () => { + stubFetch(() => apiErrorResponse(404, 'not_found', 'Unknown version')); + + await expect(fetchAssetFiles({ name: 'x', type: 'skill', version: '9.9.9' }, { client })).rejects.toBeInstanceOf( + RegistryNotFoundError, + ); }); }); describe('fetchBundleManifest', () => { - it('fetches a bundle.json by name', async () => { - const bundleJson = JSON.stringify({ + it('fetches a global bundle.json by name and version', async () => { + const bundleJson = { assets: [{ name: 'dev-commands-rule', type: 'rule' }], author: 'EmergentSoftware', description: 'd', name: 'quality-bundle', version: '0.3.0', - }); - const { octokit, spy } = makeFakeOctokit([bundleJson]); + }; + const { calls } = stubFetch(() => jsonResponse(bundleJson)); - const result = await fetchBundleManifest( - { name: 'quality-bundle', version: '0.3.0' }, - { octokit, retry: fastRetry }, - ); + const result = await fetchBundleManifest({ name: 'quality-bundle', version: '0.3.0' }, { client }); expect(result.name).toBe('quality-bundle'); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ path: 'bundles/quality-bundle/0.3.0/bundle.json' }), - ); + expect(calls[0]!.url).toBe(`${API_BASE}/bundles/quality-bundle/0.3.0/manifest`); }); - it('builds an @org-scoped path for an org bundle', async () => { - const bundleJson = JSON.stringify({ + it('passes the org as a query parameter for an org bundle', async () => { + const bundleJson = { assets: [{ name: 'dev-commands-rule', type: 'rule' }], author: 'cupay', description: 'd', name: 'qa-bundle', org: 'cupay', version: '1.0.0', - }); - const { octokit, spy } = makeFakeOctokit([bundleJson]); + }; + const { calls } = stubFetch(() => jsonResponse(bundleJson)); - const result = await fetchBundleManifest( - { name: 'qa-bundle', org: 'cupay', version: '1.0.0' }, - { octokit, retry: fastRetry }, - ); + const result = await fetchBundleManifest({ name: 'qa-bundle', org: 'cupay', version: '1.0.0' }, { client }); expect(result.name).toBe('qa-bundle'); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ path: 'bundles/@cupay/qa-bundle/1.0.0/bundle.json' }), - ); + expect(calls[0]!.url).toBe(`${API_BASE}/bundles/qa-bundle/1.0.0/manifest?org=cupay`); }); }); diff --git a/src/__tests__/lib/registry-paths.test.ts b/src/__tests__/lib/registry-paths.test.ts deleted file mode 100644 index 792bba9..0000000 --- a/src/__tests__/lib/registry-paths.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - assetPathSegments, - bundlePathSegments, - encodePathSegment, - encodeRegistryPath, - toRegistryPath, -} from '@/lib/registry-paths'; - -describe('registry-paths', () => { - describe('assetPathSegments', () => { - it('builds an unscoped asset path', () => { - const segments = assetPathSegments( - { name: 'clarification-agent', type: 'agent', version: '1.0.0' }, - 'manifest.json', - ); - expect(toRegistryPath(segments)).toBe('assets/agents/clarification-agent/1.0.0/manifest.json'); - }); - - it('prepends a bare org with @ (stored org never carries @)', () => { - const segments = assetPathSegments( - { name: 'validate', org: 'agentic-toolkit', type: 'agent', version: '1.1.0' }, - 'manifest.json', - ); - expect(toRegistryPath(segments)).toBe( - 'assets/agents/@agentic-toolkit/validate/1.1.0/manifest.json', - ); - }); - - it('splits a nested file path into segments', () => { - const segments = assetPathSegments( - { name: 'my-skill', type: 'skill', version: '2.0.0' }, - 'references/data.md', - ); - expect(toRegistryPath(segments)).toBe( - 'assets/skills/my-skill/2.0.0/references/data.md', - ); - }); - - it('omits the trailing file when none is given (version directory)', () => { - const segments = assetPathSegments({ name: 'x', type: 'rule', version: '1.0.0' }); - expect(toRegistryPath(segments)).toBe('assets/rules/x/1.0.0'); - }); - }); - - describe('bundlePathSegments', () => { - it('defaults to bundle.json and builds a global bundle path', () => { - expect(toRegistryPath(bundlePathSegments({ name: 'quality-bundle', version: '0.3.0' }))).toBe( - 'bundles/quality-bundle/0.3.0/bundle.json', - ); - }); - - it('prepends a bare org with @ for org-scoped bundles', () => { - expect( - toRegistryPath(bundlePathSegments({ name: 'qa-bundle', org: 'cupay', version: '1.0.0' })), - ).toBe('bundles/@cupay/qa-bundle/1.0.0/bundle.json'); - }); - }); - - describe('encoding', () => { - it('preserves a leading @ but percent-encodes other reserved characters', () => { - expect(encodePathSegment('@cupay')).toBe('@cupay'); - expect(encodePathSegment('a b')).toBe('a%20b'); - }); - - it('encodes each segment while preserving @ across the joined path', () => { - const segments = bundlePathSegments({ name: 'qa bundle', org: 'cupay', version: '1.0.0' }); - expect(encodeRegistryPath(segments)).toBe('bundles/@cupay/qa%20bundle/1.0.0/bundle.json'); - }); - }); -}); diff --git a/src/__tests__/providers/SessionProvider.test.tsx b/src/__tests__/providers/SessionProvider.test.tsx index 3fc7646..f8e590b 100644 --- a/src/__tests__/providers/SessionProvider.test.tsx +++ b/src/__tests__/providers/SessionProvider.test.tsx @@ -8,24 +8,9 @@ import { useSession } from '@/hooks/useSession'; import { SESSION_STORAGE_KEYS } from '@/lib/session'; import { SessionProvider } from '@/providers/SessionProvider'; -// Control what each new Octokit() instance returns. Update per-test before render. -const octokitControl: { - authenticated: () => Promise; - getMembership: () => Promise; -} = { - authenticated: async () => ({ data: { avatar_url: null, login: 'tester', name: null } }), - getMembership: async () => ({ data: { role: 'member', state: 'active' } }), -}; - -vi.mock('@octokit/rest', () => ({ - Octokit: class { - rest = { - orgs: { getMembershipForAuthenticatedUser: () => octokitControl.getMembership() }, - repos: { getContent: async () => ({ data: '' }) }, - users: { getAuthenticated: () => octokitControl.authenticated() }, - }; - }, -})); +import { apiErrorResponse, jsonResponse, stubFetch } from '../utils/api-stub'; + +const PRINCIPAL = { avatarUrl: null, login: 'tester', name: null, scheme: 'github' }; function Probe() { const session = useSession(); @@ -33,6 +18,7 @@ function Probe() {
{session.status} {session.user?.login ?? ''} + {session.api ? 'yes' : 'no'}
); } @@ -51,59 +37,79 @@ function wrap(children: ReactNode) { describe('SessionProvider', () => { beforeEach(() => { window.sessionStorage.clear(); - octokitControl.authenticated = async () => ({ - data: { avatar_url: null, login: 'tester', name: null }, - }); - octokitControl.getMembership = async () => ({ data: { role: 'member', state: 'active' } }); }); afterEach(() => { window.sessionStorage.clear(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it('is signed-out when no token is in sessionStorage', () => { + it('is signed-out with no API client when no token is in sessionStorage', () => { + const { calls } = stubFetch(() => jsonResponse(PRINCIPAL)); render(wrap()); expect(screen.getByTestId('status')).toHaveTextContent('signed-out'); + expect(screen.getByTestId('has-api')).toHaveTextContent('no'); + expect(calls).toHaveLength(0); }); - it('rehydrates the token from sessionStorage on mount and verifies membership', async () => { + it('rehydrates the token from sessionStorage on mount and verifies membership via GET /me', async () => { window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_rehydrated'); + const { calls } = stubFetch(() => jsonResponse(PRINCIPAL)); render(wrap()); // Starts in verifying while the query runs. expect(screen.getByTestId('status')).toHaveTextContent('verifying'); - // Resolves to member when checkMembershipForUser succeeds. + // Resolves to member when /me succeeds (the API enforces org membership). await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('member')); expect(screen.getByTestId('user')).toHaveTextContent('tester'); + expect(screen.getByTestId('has-api')).toHaveTextContent('yes'); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('http://localhost:7071/me'); + expect(calls[0]!.headers.get('authorization')).toBe('Bearer gho_rehydrated'); }); - it('transitions to non-member when the membership check 404s', async () => { + it('transitions to non-member on 403 not_org_member', async () => { window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_nonmember'); - octokitControl.getMembership = async () => { - const err = new Error('not a member') as Error & { status: number }; - err.status = 404; - throw err; - }; + stubFetch(() => apiErrorResponse(403, 'not_org_member', 'Not an active member')); render(wrap()); await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('non-member')); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBe('gho_nonmember'); }); - it('transitions to non-member when membership is pending (not yet active)', async () => { - window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_pending'); - octokitControl.getMembership = async () => ({ data: { role: 'member', state: 'pending' } }); + it('transitions to non-member on 403 org_membership_unverifiable and logs the SAML / OAuth-App hints', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_unverifiable'); + stubFetch(() => apiErrorResponse(403, 'org_membership_unverifiable', 'Could not verify membership')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render(wrap()); await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('non-member')); + await waitFor(() => expect(warn).toHaveBeenCalled()); + const payload = JSON.stringify(warn.mock.calls[0]); + expect(payload).toMatch(/policies\/applications/); + expect(payload).toMatch(/SAML/); + }); + + it('returns to signed-out and clears the stored token on 401', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_expired'); + stubFetch(() => apiErrorResponse(401, 'unauthorized', 'Bad credentials')); + + render(wrap()); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('signed-out')); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); + expect(screen.getByTestId('has-api')).toHaveTextContent('no'); }); it('signOut() clears the token and returns to signed-out', async () => { window.sessionStorage.setItem(SESSION_STORAGE_KEYS.token, 'gho_bye'); + stubFetch(() => jsonResponse(PRINCIPAL)); function SignOutButton() { const { signOut, status } = useSession(); @@ -117,15 +123,11 @@ describe('SessionProvider', () => { render(wrap()); // Wait for verify query to settle before signing out. - await waitFor(() => - expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('member'), - ); + await waitFor(() => expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('member')); screen.getByTestId('sign-out').click(); - await waitFor(() => - expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('signed-out'), - ); + await waitFor(() => expect(screen.getByTestId('sign-out').getAttribute('data-status')).toBe('signed-out')); expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.token)).toBeNull(); }); }); diff --git a/src/__tests__/routes/AssetDetail.test.tsx b/src/__tests__/routes/AssetDetail.test.tsx index e4b1a71..4031de7 100644 --- a/src/__tests__/routes/AssetDetail.test.tsx +++ b/src/__tests__/routes/AssetDetail.test.tsx @@ -6,21 +6,30 @@ import { type ReactNode } from 'react'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AssetFileList } from '@/lib/registry-client'; import type { Manifest, Registry } from '@/lib/schemas'; import { RegistryNotFoundError } from '@/lib/registry-errors'; import { AssetDetailRoute } from '@/routes/AssetDetail'; +const useAssetFilesMock = vi.hoisted(() => vi.fn()); const useAssetManifestMock = vi.hoisted(() => vi.fn()); const useAssetReadmeMock = vi.hoisted(() => vi.fn()); const useDownloadAssetMock = vi.hoisted(() => vi.fn(() => ({ download: vi.fn().mockResolvedValue(undefined), isDownloading: () => false })), ); const useManifestGraphMock = vi.hoisted(() => - vi.fn(() => ({ error: null, isLoading: false, manifests: new Map(), order: [] })), + vi.fn(() => ({ + error: null as Error | null, + files: new Map(), + isLoading: false, + manifests: new Map(), + order: [] as string[], + })), ); const useRegistryMock = vi.hoisted(() => vi.fn()); +vi.mock('@/hooks/useAssetFiles', () => ({ useAssetFiles: useAssetFilesMock })); vi.mock('@/hooks/useAssetManifest', () => ({ useAssetManifest: useAssetManifestMock })); vi.mock('@/hooks/useAssetReadme', () => ({ useAssetReadme: useAssetReadmeMock })); vi.mock('@/hooks/useDownloadAsset', () => ({ useDownloadAsset: useDownloadAssetMock })); @@ -31,11 +40,18 @@ vi.mock('@/hooks/useManifestGraph', () => ({ })); vi.mock('@/hooks/useRegistry', () => ({ useRegistry: useRegistryMock })); +type FilesQueryShape = Partial>; type ManifestQueryShape = Partial>; type ReadmeQueryShape = Partial>; type RegistryQueryShape = Partial>; -function buildRegistryWith(asset: { latest: string; name: string; org?: string; type: Manifest['type']; versions: string[] }): Registry { +function buildRegistryWith(asset: { + latest: string; + name: string; + org?: string; + type: Manifest['type']; + versions: string[]; +}): Registry { const versions: Registry['assets'][number]['versions'] = {}; for (const v of asset.versions) { versions[v] = { @@ -62,9 +78,7 @@ function buildRegistryWith(asset: { latest: string; name: string; org?: string; function LocationProbe() { const loc = useLocation(); - return ( -
- ); + return
; } function renderAt(path: string) { @@ -85,6 +99,17 @@ function renderAt(path: string) { return render(, { wrapper: Wrapper }); } +function setFiles(state: FilesQueryShape) { + useAssetFilesMock.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isLoading: false, + isSuccess: false, + ...state, + }); +} + function setManifest(state: ManifestQueryShape) { useAssetManifestMock.mockReturnValue({ data: undefined, @@ -172,14 +197,79 @@ See [docs](https://example.com). describe('AssetDetailRoute', () => { beforeEach(() => { setRegistry({ data: undefined }); + setFiles({ data: undefined }); }); afterEach(() => { + useAssetFilesMock.mockReset(); useAssetManifestMock.mockReset(); useAssetReadmeMock.mockReset(); useRegistryMock.mockReset(); }); + it('lists the files from the API directory listing, with dependency files from the graph', () => { + setManifest({ data: FULL_MANIFEST, isSuccess: true }); + setReadme({ data: null, isSuccess: true }); + setFiles({ + data: { + files: [ + { path: 'AGENT.md', sha: 'a', size: 1 }, + { path: 'README.md', sha: 'b', size: 1 }, + { path: 'manifest.json', sha: 'c', size: 1 }, + { path: 'reference/checks.md', sha: 'd', size: 1 }, + ], + name: 'validate', + org: 'agentic-toolkit', + type: 'agent', + version: '1.1.0', + }, + isSuccess: true, + }); + const depKey = 'rule::dev-commands-rule:^1.0.0'; + useManifestGraphMock.mockReturnValueOnce({ + error: null, + files: new Map([[depKey, ['RULE.md', 'manifest.json']]]), + isLoading: false, + manifests: new Map([ + [ + depKey, + { + author: 'x', + description: 'dep', + entrypoint: 'RULE.md', + name: 'dev-commands-rule', + type: 'rule', + version: '^1.0.0', + } as Manifest, + ], + ]), + order: [depKey], + }); + + renderAt('/assets/agent/validate/1.1.0?org=agentic-toolkit'); + + expect(useAssetFilesMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'validate', org: 'agentic-toolkit', type: 'agent', version: '1.1.0' }), + ); + const primary = screen.getByTestId('files-group-validate'); + expect(within(primary).getByText('AGENT.md')).toBeInTheDocument(); + expect(within(primary).getByText('reference/checks.md')).toBeInTheDocument(); + expect(within(primary).getByText('manifest.json')).toBeInTheDocument(); + + const dep = screen.getByTestId('files-group-dev-commands-rule-^1.0.0'); + expect(within(dep).getByText('RULE.md')).toBeInTheDocument(); + }); + + it('shows the files loading state while the listing is inflight', () => { + setManifest({ data: MINIMAL_MANIFEST, isSuccess: true }); + setReadme({ data: null, isSuccess: true }); + setFiles({ isLoading: true }); + + renderAt('/assets/skill/bare-skill/0.1.0'); + + expect(screen.getByTestId('files-card-loading')).toBeInTheDocument(); + }); + it('renders every field of a fully populated manifest', () => { setManifest({ data: FULL_MANIFEST, isSuccess: true }); setReadme({ data: '# Ready', isSuccess: true }); @@ -343,10 +433,9 @@ describe('AssetDetailRoute', () => { expect(screen.getByTestId('asset-detail-version-selector')).toHaveTextContent('v1.0.0'); fireEvent.click(screen.getByRole('button', { name: /download validate/i })); - expect(download).toHaveBeenCalledWith( - expect.objectContaining({ name: 'validate', version: '1.0.0' }), - { format: 'zip' }, - ); + expect(download).toHaveBeenCalledWith(expect.objectContaining({ name: 'validate', version: '1.0.0' }), { + format: 'zip', + }); }); }); }); diff --git a/src/__tests__/routes/AuthCallback.test.tsx b/src/__tests__/routes/AuthCallback.test.tsx index 795d651..0215ec4 100644 --- a/src/__tests__/routes/AuthCallback.test.tsx +++ b/src/__tests__/routes/AuthCallback.test.tsx @@ -62,7 +62,7 @@ describe('AuthCallbackRoute', () => { JSON.stringify({ returnPath: '/bundles', state: 'abc-state' }), ); const fetchMock = vi.fn( - async (_url: string, _init?: RequestInit) => + async (_input: Request | string | URL, _init?: RequestInit) => new Response(JSON.stringify({ access_token: 'gho_test-token' }), { headers: { 'content-type': 'application/json' }, status: 200, @@ -78,24 +78,31 @@ describe('AuthCallbackRoute', () => { expect(fetchMock).toHaveBeenCalledTimes(1); const call = fetchMock.mock.calls[0]; if (!call) throw new Error('fetch was not called'); - expect(String(call[0])).toContain('/api/auth/exchange'); - expect(call[1]?.method).toBe('POST'); + const request = call[0] as Request; + expect(request.url).toBe('http://localhost:7071/auth/github/exchange'); + expect(request.method).toBe('POST'); + expect(request.headers.get('authorization')).toBeNull(); + await expect(request.clone().json()).resolves.toEqual({ code: 'abc' }); }); it('renders an error when the exchange call fails', async () => { - window.sessionStorage.setItem( - SESSION_STORAGE_KEYS.oauthState, - JSON.stringify({ returnPath: '/', state: 'abc' }), - ); + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.oauthState, JSON.stringify({ returnPath: '/', state: 'abc' })); vi.stubGlobal( 'fetch', - vi.fn(async () => new Response('{"error":"invalid_request"}', { status: 400 })), + vi.fn( + async () => + new Response('{"error":"bad_verification_code","message":"The code passed is incorrect or expired."}', { + headers: { 'content-type': 'application/json' }, + status: 400, + }), + ), ); renderCallback('?code=abc&state=abc'); await waitFor(() => - expect(screen.getByTestId('auth-callback-error')).toHaveTextContent(/auth exchange failed/i), + expect(screen.getByTestId('auth-callback-error')).toHaveTextContent(/auth exchange failed \(HTTP 400\)/i), ); + expect(screen.getByTestId('auth-callback-error')).toHaveTextContent(/incorrect or expired/i); }); }); diff --git a/src/__tests__/routes/BundleDetail.test.tsx b/src/__tests__/routes/BundleDetail.test.tsx index 0099c23..762c205 100644 --- a/src/__tests__/routes/BundleDetail.test.tsx +++ b/src/__tests__/routes/BundleDetail.test.tsx @@ -6,7 +6,7 @@ import { type ReactNode } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Bundle, Registry } from '@/lib/schemas'; +import type { Bundle, Manifest, Registry } from '@/lib/schemas'; import { RegistryNotFoundError } from '@/lib/registry-errors'; import { BundleDetailRoute } from '@/routes/BundleDetail'; @@ -19,7 +19,13 @@ const useDownloadBundleMock = vi.hoisted(() => vi.fn(() => ({ download: vi.fn().mockResolvedValue(undefined), isDownloading: () => false })), ); const useManifestGraphMock = vi.hoisted(() => - vi.fn(() => ({ error: null, isLoading: false, manifests: new Map(), order: [] })), + vi.fn(() => ({ + error: null as Error | null, + files: new Map(), + isLoading: false, + manifests: new Map(), + order: [] as string[], + })), ); vi.mock('@/hooks/useBundleManifest', () => ({ useBundleManifest: useBundleManifestMock })); @@ -122,15 +128,53 @@ describe('BundleDetailRoute', () => { // validate has an explicit version + org const validateLink = within(assetsSection).getByRole('link', { name: /open validate/i }); - expect(validateLink).toHaveAttribute( - 'href', - '/assets/agent/validate/1.1.0?org=agentic-toolkit', - ); + expect(validateLink).toHaveAttribute('href', '/assets/agent/validate/1.1.0?org=agentic-toolkit'); const setup = screen.getByTestId('bundle-detail-setup'); expect(within(setup).getByRole('heading', { level: 2, name: 'Setup' })).toBeInTheDocument(); }); + it('lists bundle.json plus each member file from the API listing under the bundle group', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + const validateKey = 'agent:agentic-toolkit:validate:1.1.0'; + useManifestGraphMock.mockReturnValue({ + error: null, + files: new Map([[validateKey, ['AGENT.md', 'manifest.json', 'reference/checks.md']]]), + isLoading: false, + manifests: new Map([ + [ + validateKey, + { + author: 'x', + description: 'v', + entrypoint: 'AGENT.md', + name: 'validate', + org: 'agentic-toolkit', + type: 'agent', + version: '1.1.0', + } as Manifest, + ], + ]), + order: [validateKey], + }); + + renderAt('/bundles/feature-workflow'); + + const group = screen.getByTestId('files-group-feature-workflow'); + expect(within(group).getByText('bundle.json')).toBeInTheDocument(); + expect(within(group).getByText('validate/AGENT.md')).toBeInTheDocument(); + expect(within(group).getByText('validate/reference/checks.md')).toBeInTheDocument(); + useManifestGraphMock.mockReset(); + useManifestGraphMock.mockReturnValue({ + error: null, + files: new Map(), + isLoading: false, + manifests: new Map(), + order: [], + }); + }); + it('downloads the bundle in the chosen format from the header menu', () => { const download = vi.fn().mockResolvedValue(undefined); useDownloadBundleMock.mockReturnValueOnce({ download, isDownloading: () => false }); @@ -144,7 +188,7 @@ describe('BundleDetailRoute', () => { expect(download).toHaveBeenCalledWith( 'feature-workflow', - expect.objectContaining({ format: 'skill', resolveVersion: expect.any(Function), version: '1.0.0' }), + expect.objectContaining({ format: 'skill', version: '1.0.0' }), ); }); diff --git a/src/__tests__/routes/Bundles.test.tsx b/src/__tests__/routes/Bundles.test.tsx index 7ef9d3f..ee690d4 100644 --- a/src/__tests__/routes/Bundles.test.tsx +++ b/src/__tests__/routes/Bundles.test.tsx @@ -61,10 +61,7 @@ function renderBundles() { - BUNDLE DETAIL
} - path='/bundles/:bundleId' - /> + BUNDLE DETAIL
} path='/bundles/:bundleId' /> ORG BUNDLE DETAIL} path='/bundles/:org/:name' @@ -202,9 +199,7 @@ describe('BundlesRoute', () => { mockUseRegistry({ data: loadFixtureRegistry(), isSuccess: true }); renderBundles(); - expect(screen.getAllByTestId('bundle-scoped-hint-feature-workflow')[0]).toHaveTextContent( - /\+1 org/i, - ); + expect(screen.getAllByTestId('bundle-scoped-hint-feature-workflow')[0]).toHaveTextContent(/\+1 org/i); }); it('passes the bundle org to the download hook for an org-scoped bundle (W3)', () => { @@ -219,13 +214,10 @@ describe('BundlesRoute', () => { fireEvent.click(screen.getAllByTestId(`bundles-download-${name}`)[0]!); fireEvent.click(screen.getAllByTestId(`bundles-download-${name}-zip`)[0]!); - expect(download).toHaveBeenCalledWith( - name, - expect.objectContaining({ format: 'zip', org: 'cupay' }), - ); + expect(download).toHaveBeenCalledWith(name, expect.objectContaining({ format: 'zip', org: 'cupay' })); }); - it('invokes the download hook (with resolveVersion and format) when a row download option is chosen', () => { + it('invokes the download hook with the chosen format when a row download option is chosen', () => { const download = vi.fn().mockResolvedValue(undefined); useDownloadBundleMock.mockReturnValueOnce({ download, isDownloading: () => false }); mockUseRegistry({ data: loadFixtureRegistry(), isSuccess: true }); @@ -236,7 +228,7 @@ describe('BundlesRoute', () => { expect(download).toHaveBeenCalledWith( 'feature-workflow', - expect.objectContaining({ format: 'zip', resolveVersion: expect.any(Function) }), + expect.objectContaining({ format: 'zip', version: '1.0.0' }), ); }); @@ -251,7 +243,7 @@ describe('BundlesRoute', () => { expect(download).toHaveBeenCalledWith( 'feature-workflow', - expect.objectContaining({ format: 'skill', resolveVersion: expect.any(Function) }), + expect.objectContaining({ format: 'skill', version: '1.0.0' }), ); }); @@ -340,11 +332,7 @@ describe('BundlesRoute', () => { renderBundles(); const tableWrapper = screen.getByTestId('bundles-table-wrapper'); - expect( - within(tableWrapper).queryByRole('columnheader', { name: /^author$/i }), - ).not.toBeInTheDocument(); - expect( - within(tableWrapper).queryByRole('columnheader', { name: /^tags$/i }), - ).not.toBeInTheDocument(); + expect(within(tableWrapper).queryByRole('columnheader', { name: /^author$/i })).not.toBeInTheDocument(); + expect(within(tableWrapper).queryByRole('columnheader', { name: /^tags$/i })).not.toBeInTheDocument(); }); }); diff --git a/src/__tests__/routes/Contribute.test.tsx b/src/__tests__/routes/Contribute.test.tsx index 35fd3df..55350d3 100644 --- a/src/__tests__/routes/Contribute.test.tsx +++ b/src/__tests__/routes/Contribute.test.tsx @@ -1,11 +1,11 @@ -import type { Octokit } from '@octokit/rest'; - import { Toast } from '@base-ui-components/react/toast'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import JSZip from 'jszip'; import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ApiClient } from '@/lib/api-client'; + import { Toaster } from '@/components/Toaster'; import * as publishServiceModule from '@/lib/publish-service'; import * as registryClientModule from '@/lib/registry-client'; @@ -20,6 +20,7 @@ import { } from '@/routes/Contribute'; import { loadFixtureRegistry } from '../fixtures'; +import { makeTestApiClient } from '../utils/api-stub'; import { makeSessionValue, SessionHarness } from '../utils/session-harness'; interface FakeDirTree { @@ -89,11 +90,7 @@ function makeFakeEntry(name: string, fullPath: string, node: FakeDirTree | File) return; } readCount++; - cb( - childKeys.map((key) => - makeFakeEntry(key, `${fullPath}/${key}`, node[key]!), - ), - ); + cb(childKeys.map((key) => makeFakeEntry(key, `${fullPath}/${key}`, node[key]!))); }, }), fullPath, @@ -107,22 +104,16 @@ function makeFile(name: string, content = '# content') { return new File([content], name, { type: 'text/markdown' }); } -async function makeSkillFile( - name: string, - entries: Record, -): Promise { +async function makeSkillFile(name: string, entries: Record): Promise { const zip = new JSZip(); for (const [path, content] of Object.entries(entries)) zip.file(path, content); const blob = await zip.generateAsync({ type: 'blob' }); return new File([blob], name, { type: 'application/zip' }); } -function renderContribute( - login = 'test-user', - options: { initialEntries?: string[]; octokit?: null | Octokit } = {}, -) { +function renderContribute(login = 'test-user', options: { api?: ApiClient | null; initialEntries?: string[] } = {}) { const session = makeSessionValue({ - octokit: options.octokit ?? null, + api: options.api ?? null, status: 'member', user: { avatarUrl: null, login, name: null }, }); @@ -359,9 +350,7 @@ describe('Contribute — wizard UI', () => { expect(screen.getByTestId('files-list')).not.toHaveTextContent('.skill'); fireEvent.click(screen.getByTestId('wizard-next')); - expect((screen.getByTestId('field-name') as HTMLInputElement).value).toBe( - 'emergent-qa-refinement', - ); + expect((screen.getByTestId('field-name') as HTMLInputElement).value).toBe('emergent-qa-refinement'); expect((screen.getByTestId('field-description') as HTMLInputElement).value).toBe( 'Refines QA test cases into a structured plan.', ); @@ -406,16 +395,15 @@ describe('Contribute — wizard UI', () => { }); it('runs a happy-path end-to-end submission and calls the publish service', async () => { - const publishSpy = vi - .spyOn(publishServiceModule, 'publishContribution') - .mockResolvedValue({ - branchName: 'asset/skill/happy-path-skill/1.0.0', - dryRun: false, - prUrl: 'https://github.com/EmergentSoftware/agentic-toolkit-registry/pull/9', - }); + const publishSpy = vi.spyOn(publishServiceModule, 'publishContribution').mockResolvedValue({ + branchName: 'asset/skill/happy-path-skill/1.0.0', + dryRun: false, + prUrl: 'https://github.com/EmergentSoftware/agentic-toolkit-registry/pull/9', + warnings: [], + }); - const fakeOctokit = { rest: {} } as unknown as Octokit; - renderContribute('octo-login', { octokit: fakeOctokit }); + const fakeApi = makeTestApiClient(); + renderContribute('octo-login', { api: fakeApi }); // Step 1 fireEvent.click(screen.getByTestId('asset-type-skill')); @@ -447,6 +435,7 @@ describe('Contribute — wizard UI', () => { const callArgs = publishSpy.mock.calls[0]![0]; expect(callArgs).toMatchObject({ + client: fakeApi, dryRun: false, manifest: expect.objectContaining({ author: 'octo-login', @@ -454,12 +443,9 @@ describe('Contribute — wizard UI', () => { type: 'skill', version: '1.0.0', }), - octokit: fakeOctokit, readme: '# Hello', }); - expect(callArgs.files).toEqual([ - expect.objectContaining({ content: '# Skill', path: 'skill.md' }), - ]); + expect(callArgs.files).toEqual([expect.objectContaining({ content: '# Skill', path: 'skill.md' })]); await waitFor(() => { expect(window.sessionStorage.getItem(DRAFT_STORAGE_KEY)).toBeNull(); @@ -467,18 +453,17 @@ describe('Contribute — wizard UI', () => { }); it('enables dry-run mode when ?dryRun=1 is present in the URL', async () => { - const publishSpy = vi - .spyOn(publishServiceModule, 'publishContribution') - .mockResolvedValue({ - branchName: 'asset/skill/dry-skill/1.0.0', - dryRun: true, - prUrl: publishServiceModule.DRY_RUN_PR_URL_MARKER, - }); - - const fakeOctokit = { rest: {} } as unknown as Octokit; + const publishSpy = vi.spyOn(publishServiceModule, 'publishContribution').mockResolvedValue({ + branchName: 'asset/skill/dry-skill/1.0.0', + dryRun: true, + prUrl: publishServiceModule.DRY_RUN_PR_URL_MARKER, + warnings: [], + }); + + const fakeApi = makeTestApiClient(); renderContribute('octo-login', { + api: fakeApi, initialEntries: ['/contribute?dryRun=1'], - octokit: fakeOctokit, }); fireEvent.click(screen.getByTestId('asset-type-skill')); @@ -511,8 +496,8 @@ describe('Contribute — wizard UI', () => { }); describe('Contribute — version conflict detection', () => { - async function advanceToMetadata(octokit: Octokit) { - renderContribute('octo-login', { octokit }); + async function advanceToMetadata(api: ApiClient) { + renderContribute('octo-login', { api }); fireEvent.click(screen.getByTestId('asset-type-skill')); fireEvent.click(screen.getByTestId('wizard-next')); await uploadFiles([makeFile('skill.md', '# Skill')]); @@ -524,8 +509,8 @@ describe('Contribute — version conflict detection', () => { it('blocks Next and shows bump buttons when the version is not newer than the registry latest', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - await advanceToMetadata(octokit); + const api = makeTestApiClient(); + await advanceToMetadata(api); fillMetadata({ description: 'desc', name: 'feature-skill', version: '0.2.0' }); await flush(); @@ -543,8 +528,8 @@ describe('Contribute — version conflict detection', () => { it('shows an update badge when the version is strictly greater than the registry latest', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - await advanceToMetadata(octokit); + const api = makeTestApiClient(); + await advanceToMetadata(api); fillMetadata({ description: 'desc', name: 'feature-skill', version: '0.3.0' }); await flush(); @@ -556,8 +541,8 @@ describe('Contribute — version conflict detection', () => { it('shows no conflict UI when the asset name does not exist in the registry', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - await advanceToMetadata(octokit); + const api = makeTestApiClient(); + await advanceToMetadata(api); fillMetadata({ description: 'desc', name: 'brand-new-skill', version: '1.0.0' }); await flush(); @@ -568,8 +553,8 @@ describe('Contribute — version conflict detection', () => { it('carries the update badge into the Review step', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - await advanceToMetadata(octokit); + const api = makeTestApiClient(); + await advanceToMetadata(api); fillMetadata({ description: 'desc', name: 'feature-skill', version: '0.3.0' }); await flush(); fireEvent.click(screen.getByTestId('wizard-next')); @@ -580,8 +565,8 @@ describe('Contribute — version conflict detection', () => { it('recomputes on org change — adding the matching org reveals the conflict panel', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - renderContribute('octo-login', { octokit }); + const api = makeTestApiClient(); + renderContribute('octo-login', { api }); fireEvent.click(screen.getByTestId('asset-type-agent')); fireEvent.click(screen.getByTestId('wizard-next')); await uploadFiles([makeFile('agent.md', '# Agent')]); @@ -602,8 +587,8 @@ describe('Contribute — version conflict detection', () => { it('shows no conflict panel when an org-scoped draft shares a name with a global-only registry entry', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - await advanceToMetadata(octokit); + const api = makeTestApiClient(); + await advanceToMetadata(api); // "feature-skill" exists only as a global entry in the fixture; an org-scoped // draft of the same name+type must not be matched against it. fillMetadata({ description: 'desc', name: 'feature-skill', version: '0.2.0' }); @@ -617,8 +602,8 @@ describe('Contribute — version conflict detection', () => { it('shows no conflict panel when a global draft shares a name with an org-scoped-only registry entry', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - renderContribute('octo-login', { octokit }); + const api = makeTestApiClient(); + renderContribute('octo-login', { api }); fireEvent.click(screen.getByTestId('asset-type-agent')); fireEvent.click(screen.getByTestId('wizard-next')); await uploadFiles([makeFile('agent.md', '# Agent')]); @@ -637,8 +622,8 @@ describe('Contribute — version conflict detection', () => { it('shows no conflict panel when an org-scoped draft matches a different org-scoped registry entry', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - renderContribute('octo-login', { octokit }); + const api = makeTestApiClient(); + renderContribute('octo-login', { api }); fireEvent.click(screen.getByTestId('asset-type-agent')); fireEvent.click(screen.getByTestId('wizard-next')); await uploadFiles([makeFile('agent.md', '# Agent')]); @@ -658,8 +643,8 @@ describe('Contribute — version conflict detection', () => { it('still triggers the conflict panel when an org-scoped draft matches a same-org registry entry', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - renderContribute('octo-login', { octokit }); + const api = makeTestApiClient(); + renderContribute('octo-login', { api }); fireEvent.click(screen.getByTestId('asset-type-agent')); fireEvent.click(screen.getByTestId('wizard-next')); await uploadFiles([makeFile('agent.md', '# Agent')]); @@ -676,8 +661,8 @@ describe('Contribute — version conflict detection', () => { it('still triggers the conflict panel when a global draft matches a global registry entry', async () => { vi.spyOn(registryClientModule, 'fetchRegistry').mockResolvedValue(loadFixtureRegistry()); - const octokit = { rest: {} } as unknown as Octokit; - await advanceToMetadata(octokit); + const api = makeTestApiClient(); + await advanceToMetadata(api); fillMetadata({ description: 'desc', name: 'feature-skill', version: '0.1.0' }); await flush(); diff --git a/src/__tests__/routes/CreateBundle.test.tsx b/src/__tests__/routes/CreateBundle.test.tsx index a1c32a0..cd4a271 100644 --- a/src/__tests__/routes/CreateBundle.test.tsx +++ b/src/__tests__/routes/CreateBundle.test.tsx @@ -1,4 +1,3 @@ -import type { Octokit } from '@octokit/rest'; import type { UseQueryResult } from '@tanstack/react-query'; import { Toast } from '@base-ui-components/react/toast'; @@ -7,6 +6,7 @@ import { type ReactNode } from 'react'; import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ApiClient } from '@/lib/api-client'; import type { Registry } from '@/lib/schemas'; import { Toaster } from '@/components/Toaster'; @@ -22,14 +22,15 @@ import { } from '@/routes/CreateBundle'; import { loadFixtureRegistry } from '../fixtures'; -import { makeSessionValue, SessionHarness, stubOctokit } from '../utils/session-harness'; +import { makeTestApiClient } from '../utils/api-stub'; +import { makeSessionValue, SessionHarness } from '../utils/session-harness'; const useRegistryMock = vi.hoisted(() => vi.fn()); vi.mock('@/hooks/useRegistry', () => ({ useRegistry: useRegistryMock })); -function renderCreateBundle(octokit: null | Octokit = stubOctokit(async () => ({ data: '' }))) { +function renderCreateBundle(api: ApiClient | null = makeTestApiClient()) { const session = makeSessionValue({ - octokit, + api, status: 'member', user: { avatarUrl: null, login: 'test-user', name: null }, }); @@ -125,7 +126,12 @@ describe('CreateBundle — wizard flow', () => { it('walks metadata → assets → review and submits a bundle via publishBundle', async () => { const publishSpy = vi .spyOn(publishServiceModule, 'publishBundle') - .mockResolvedValue({ branchName: 'bundle/my-bundle/1.0.0', dryRun: false, prUrl: 'https://x/pull/1' }); + .mockResolvedValue({ + branchName: 'bundle/my-bundle/1.0.0', + dryRun: false, + prUrl: 'https://x/pull/1', + warnings: [], + }); renderCreateBundle(); diff --git a/src/__tests__/utils/api-stub.ts b/src/__tests__/utils/api-stub.ts new file mode 100644 index 0000000..3722de3 --- /dev/null +++ b/src/__tests__/utils/api-stub.ts @@ -0,0 +1,72 @@ +import { vi } from 'vitest'; + +import { type ApiClient, createApiClient } from '@/lib/api-client'; + +/** Matches `VITE_ATK_API_URL` stubbed in `setupTests.ts`. */ +export const API_BASE = 'http://localhost:7071'; + +/** Retry policy that keeps tests fast while still exercising one retry. */ +export const fastRetry = { baseDelayMs: 1, jitter: false, maxDelayMs: 5, maxRetries: 1 } as const; + +/** One request the stubbed `fetch` received, with its body already read. */ +export interface RecordedRequest { + body: string; + headers: Headers; + method: string; + url: string; +} + +type FetchHandler = (request: RecordedRequest, index: number) => Promise | Response; + +/** An ATK API error envelope response. */ +export function apiErrorResponse( + status: number, + code: string, + message: string, + details?: Array<{ message: string; path?: null | string }>, +): Response { + return jsonResponse({ details: details ?? null, error: code, message }, status); +} + +/** A binary (zip) response. */ +export function blobResponse(bytes: string | Uint8Array, contentType = 'application/zip'): Response { + const body = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes; + return new Response(body as BodyInit, { headers: { 'content-type': contentType }, status: 200 }); +} + +/** A JSON response. */ +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' }, status }); +} + +/** Build a real API client (bearer auth + retries) pointed at the test base URL. */ +export function makeTestApiClient(token: null | string = 'test-token', retry = fastRetry): ApiClient { + return createApiClient(token, { retry }); +} + +/** + * Replace global `fetch` with a recorder that hands each request to `handler`. + * The generated client calls `fetch(Request)`, so the stub normalises either + * calling convention and reads the body up front for assertions. + */ +export function stubFetch(handler: FetchHandler): { calls: RecordedRequest[]; mock: ReturnType } { + const calls: RecordedRequest[] = []; + const mock = vi.fn(async (input: Request | string | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const recorded: RecordedRequest = { + body: await request.clone().text(), + headers: request.headers, + method: request.method, + url: request.url, + }; + calls.push(recorded); + return handler(recorded, calls.length - 1); + }); + vi.stubGlobal('fetch', mock); + return { calls, mock }; +} + +/** A plain-text response (README markdown, or an error body without an envelope). */ +export function textResponse(text: string, status = 200, contentType = 'text/markdown'): Response { + return new Response(text, { headers: { 'content-type': contentType }, status }); +} diff --git a/src/__tests__/utils/session-harness.tsx b/src/__tests__/utils/session-harness.tsx index 40dad19..872b28e 100644 --- a/src/__tests__/utils/session-harness.tsx +++ b/src/__tests__/utils/session-harness.tsx @@ -1,4 +1,3 @@ -import type { Octokit } from '@octokit/rest'; import type { ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -14,8 +13,8 @@ interface SessionHarnessProps { /** Default no-op session. Override fields as needed per test. */ export function makeSessionValue(overrides: Partial = {}): SessionContextValue { return { + api: null, completeSignIn: () => {}, - octokit: null, signIn: () => {}, signOut: () => {}, status: 'signed-out', @@ -42,10 +41,3 @@ export function SessionHarness({ children, client, session }: SessionHarnessProp ); } - -/** Minimal Octokit stub whose only method is `rest.repos.getContent`. */ -export function stubOctokit( - getContent: (params: unknown) => Promise, -): Octokit { - return { rest: { repos: { getContent } } } as unknown as Octokit; -} diff --git a/src/components/PublishIssuesPanel.tsx b/src/components/PublishIssuesPanel.tsx new file mode 100644 index 0000000..dbbd0ce --- /dev/null +++ b/src/components/PublishIssuesPanel.tsx @@ -0,0 +1,34 @@ +import type { ApiErrorDetail } from '@/lib/api-client'; + +interface PublishIssuesPanelProps { + /** Problems reported by the registry API (`details[]` of a 400 `validation_failed` / `schema_invalid`). */ + issues: ApiErrorDetail[]; + /** Noun used in the heading, e.g. "contribution" or "bundle". */ + subject: string; +} + +/** + * Lists the per-item problems the ATK API returned when it rejected a publish + * payload. Each item carries a JSON pointer into the payload (`/manifest/name`, + * `/files/0/path`, ...) when the API could attribute it. + */ +export function PublishIssuesPanel({ issues, subject }: PublishIssuesPanelProps) { + if (issues.length === 0) return null; + return ( +
+

The registry rejected this {subject}:

+
    + {issues.map((issue, i) => ( +
  • + {issue.path ? {issue.path}: : null} + {issue.message} +
  • + ))} +
+
+ ); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e716d17..eddd67f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +export { useAssetFiles } from './useAssetFiles'; export { useAssetManifest } from './useAssetManifest'; export { useAssetReadme } from './useAssetReadme'; export { useBundleManifest } from './useBundleManifest'; diff --git a/src/hooks/useAssetFiles.ts b/src/hooks/useAssetFiles.ts new file mode 100644 index 0000000..35b741d --- /dev/null +++ b/src/hooks/useAssetFiles.ts @@ -0,0 +1,29 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { useSession } from '@/hooks/useSession'; +import { queryKeys } from '@/lib/query-keys'; +import { type AssetFileList, type AssetManifestRef, fetchAssetFiles } from '@/lib/registry-client'; + +/** + * Fetch and cache the API's file listing for an asset version (the + * authoritative file set; `manifest.files` is empty for many live assets). + * Requires an authenticated session. + */ +export function useAssetFiles(ref: Partial): UseQueryResult { + const { api } = useSession(); + const enabled = Boolean(api && ref.name && ref.type && ref.version); + + return useQuery({ + enabled, + queryFn: ({ signal }) => { + if (!api) throw new Error('useAssetFiles: no authenticated API client available'); + return fetchAssetFiles(ref as AssetManifestRef, { client: api, signal }); + }, + queryKey: queryKeys.assetFiles({ + name: ref.name ?? '', + org: ref.org, + type: ref.type ?? 'skill', + version: ref.version ?? '', + }), + }); +} diff --git a/src/hooks/useAssetManifest.ts b/src/hooks/useAssetManifest.ts index 1e039ca..bae0709 100644 --- a/src/hooks/useAssetManifest.ts +++ b/src/hooks/useAssetManifest.ts @@ -4,23 +4,18 @@ import type { Manifest } from '@/lib/schemas'; import { useSession } from '@/hooks/useSession'; import { queryKeys } from '@/lib/query-keys'; -import { type AssetManifestRef, fetchAssetManifest, type RegistryClientOptions } from '@/lib/registry-client'; - -type AssetManifestHookOptions = Omit; +import { type AssetManifestRef, fetchAssetManifest } from '@/lib/registry-client'; /** Fetch and cache a specific asset's manifest.json. Requires an authenticated session. */ -export function useAssetManifest( - ref: Partial, - options?: AssetManifestHookOptions, -): UseQueryResult { - const { octokit } = useSession(); - const enabled = Boolean(octokit && ref.name && ref.type && ref.version); +export function useAssetManifest(ref: Partial): UseQueryResult { + const { api } = useSession(); + const enabled = Boolean(api && ref.name && ref.type && ref.version); return useQuery({ enabled, queryFn: ({ signal }) => { - if (!octokit) throw new Error('useAssetManifest: no authenticated Octokit client available'); - return fetchAssetManifest(ref as AssetManifestRef, { ...options, octokit, signal }); + if (!api) throw new Error('useAssetManifest: no authenticated API client available'); + return fetchAssetManifest(ref as AssetManifestRef, { client: api, signal }); }, queryKey: queryKeys.assetManifest({ name: ref.name ?? '', diff --git a/src/hooks/useAssetReadme.ts b/src/hooks/useAssetReadme.ts index c716604..127392f 100644 --- a/src/hooks/useAssetReadme.ts +++ b/src/hooks/useAssetReadme.ts @@ -2,26 +2,21 @@ import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useSession } from '@/hooks/useSession'; import { queryKeys } from '@/lib/query-keys'; -import { type AssetManifestRef, fetchAssetReadme, type RegistryClientOptions } from '@/lib/registry-client'; - -type AssetReadmeHookOptions = Omit; +import { type AssetManifestRef, fetchAssetReadme } from '@/lib/registry-client'; /** * Fetch and cache an asset's README.md. Requires an authenticated session. * Resolves to `null` when the README is missing (HTTP 404). */ -export function useAssetReadme( - ref: Partial, - options?: AssetReadmeHookOptions, -): UseQueryResult { - const { octokit } = useSession(); - const enabled = Boolean(octokit && ref.name && ref.type && ref.version); +export function useAssetReadme(ref: Partial): UseQueryResult { + const { api } = useSession(); + const enabled = Boolean(api && ref.name && ref.type && ref.version); return useQuery({ enabled, queryFn: ({ signal }) => { - if (!octokit) throw new Error('useAssetReadme: no authenticated Octokit client available'); - return fetchAssetReadme(ref as AssetManifestRef, { ...options, octokit, signal }); + if (!api) throw new Error('useAssetReadme: no authenticated API client available'); + return fetchAssetReadme(ref as AssetManifestRef, { client: api, signal }); }, queryKey: queryKeys.assetReadme({ name: ref.name ?? '', diff --git a/src/hooks/useBundleManifest.ts b/src/hooks/useBundleManifest.ts index 4452e1b..8652f86 100644 --- a/src/hooks/useBundleManifest.ts +++ b/src/hooks/useBundleManifest.ts @@ -4,23 +4,18 @@ import type { Bundle } from '@/lib/schemas'; import { useSession } from '@/hooks/useSession'; import { queryKeys } from '@/lib/query-keys'; -import { type BundleManifestRef, fetchBundleManifest, type RegistryClientOptions } from '@/lib/registry-client'; - -type BundleManifestHookOptions = Omit; +import { type BundleManifestRef, fetchBundleManifest } from '@/lib/registry-client'; /** Fetch and cache a bundle's bundle.json. Requires an authenticated session. */ -export function useBundleManifest( - ref: Partial, - options?: BundleManifestHookOptions, -): UseQueryResult { - const { octokit } = useSession(); - const enabled = Boolean(octokit && ref.name && ref.version); +export function useBundleManifest(ref: Partial): UseQueryResult { + const { api } = useSession(); + const enabled = Boolean(api && ref.name && ref.version); return useQuery({ enabled, queryFn: ({ signal }) => { - if (!octokit) throw new Error('useBundleManifest: no authenticated Octokit client available'); - return fetchBundleManifest(ref as BundleManifestRef, { ...options, octokit, signal }); + if (!api) throw new Error('useBundleManifest: no authenticated API client available'); + return fetchBundleManifest(ref as BundleManifestRef, { client: api, signal }); }, queryKey: queryKeys.bundleManifest({ name: ref.name ?? '', org: ref.org, version: ref.version }), }); diff --git a/src/hooks/useDownloadAsset.ts b/src/hooks/useDownloadAsset.ts index 065948b..f8c98f3 100644 --- a/src/hooks/useDownloadAsset.ts +++ b/src/hooks/useDownloadAsset.ts @@ -5,8 +5,11 @@ import { type AssetRef, downloadAsset, type DownloadAssetOptions } from '@/lib/d import { useSession } from './useSession'; import { useToast } from './useToast'; +/** Per-call download options; the API client comes from the session. */ +export type DownloadAssetHookOptions = Omit; + export interface UseDownloadAssetResult { - download: (ref: AssetRef, options?: DownloadAssetOptions) => Promise; + download: (ref: AssetRef, options?: DownloadAssetHookOptions) => Promise; isDownloading: (ref: AssetRef) => boolean; } @@ -17,7 +20,7 @@ export interface UseDownloadAssetResult { */ export function useDownloadAsset(): UseDownloadAssetResult { const toast = useToast(); - const { token } = useSession(); + const { api } = useSession(); const [inFlight, setInFlight] = useState>(() => new Set()); const inFlightRef = useRef(inFlight); inFlightRef.current = inFlight; @@ -40,12 +43,20 @@ export function useDownloadAsset(): UseDownloadAssetResult { }, []); const download = useCallback( - async (ref: AssetRef, options?: DownloadAssetOptions) => { + async (ref: AssetRef, options?: DownloadAssetHookOptions) => { const key = refKey(ref); if (inFlightRef.current.has(key)) return; + if (!api) { + toast.add({ + description: 'Sign in to download assets from the registry.', + priority: 'high', + title: `Failed to download ${ref.name}`, + }); + return; + } markStart(key); try { - await downloadAsset(ref, { ...(token ? { token } : {}), ...options }); + await downloadAsset(ref, { ...options, client: api }); toast.add({ description: `${ref.name}@${ref.version} downloaded.`, priority: 'low', @@ -61,7 +72,7 @@ export function useDownloadAsset(): UseDownloadAssetResult { markDone(key); } }, - [markDone, markStart, toast, token], + [api, markDone, markStart, toast], ); const isDownloading = useCallback((ref: AssetRef) => inFlight.has(refKey(ref)), [inFlight]); diff --git a/src/hooks/useDownloadBundle.ts b/src/hooks/useDownloadBundle.ts index c282ccf..2e9dd6d 100644 --- a/src/hooks/useDownloadBundle.ts +++ b/src/hooks/useDownloadBundle.ts @@ -5,8 +5,11 @@ import { downloadBundle, type DownloadBundleOptions } from '@/lib/download-servi import { useSession } from './useSession'; import { useToast } from './useToast'; +/** Per-call download options; the API client comes from the session. */ +export type DownloadBundleHookOptions = Omit; + export interface UseDownloadBundleResult { - download: (name: string, options: DownloadBundleOptions) => Promise; + download: (name: string, options: DownloadBundleHookOptions) => Promise; isDownloading: (name: string, org?: string) => boolean; } @@ -17,7 +20,7 @@ export interface UseDownloadBundleResult { */ export function useDownloadBundle(): UseDownloadBundleResult { const toast = useToast(); - const { token } = useSession(); + const { api } = useSession(); const [inFlight, setInFlight] = useState>(() => new Set()); const inFlightRef = useRef(inFlight); inFlightRef.current = inFlight; @@ -40,12 +43,20 @@ export function useDownloadBundle(): UseDownloadBundleResult { }, []); const download = useCallback( - async (name: string, options: DownloadBundleOptions) => { + async (name: string, options: DownloadBundleHookOptions) => { const key = bundleKey(name, options.org); if (inFlightRef.current.has(key)) return; + if (!api) { + toast.add({ + description: 'Sign in to download bundles from the registry.', + priority: 'high', + title: `Failed to download ${name}`, + }); + return; + } markStart(key); try { - await downloadBundle(name, { ...(token ? { token } : {}), ...options }); + await downloadBundle(name, { ...options, client: api }); toast.add({ description: `Bundle ${name} downloaded.`, priority: 'low', @@ -61,13 +72,10 @@ export function useDownloadBundle(): UseDownloadBundleResult { markDone(key); } }, - [markDone, markStart, toast, token], + [api, markDone, markStart, toast], ); - const isDownloading = useCallback( - (name: string, org?: string) => inFlight.has(bundleKey(name, org)), - [inFlight], - ); + const isDownloading = useCallback((name: string, org?: string) => inFlight.has(bundleKey(name, org)), [inFlight]); return { download, isDownloading }; } diff --git a/src/hooks/useManifestGraph.ts b/src/hooks/useManifestGraph.ts index 44b5210..27ade1a 100644 --- a/src/hooks/useManifestGraph.ts +++ b/src/hooks/useManifestGraph.ts @@ -5,10 +5,12 @@ import type { Manifest } from '@/lib/schemas'; import { useSession } from '@/hooks/useSession'; import { queryKeys } from '@/lib/query-keys'; -import { type AssetManifestRef, fetchAssetManifest } from '@/lib/registry-client'; +import { type AssetManifestRef, fetchAssetFiles, fetchAssetManifest } from '@/lib/registry-client'; export interface ManifestGraphState { error: Error | null; + /** File paths per asset (from the API's directory listing), keyed by `refKey(ref)`. */ + files: Map; isLoading: boolean; manifests: Map; order: string[]; @@ -20,16 +22,17 @@ export function refKey(ref: AssetManifestRef): string { /** * Breadth-first-walk the given refs and all their transitive dependencies, - * fetching each manifest through react-query's cache. The returned map is - * keyed by `refKey(ref)` and enumerated in discovery order so callers can - * render groups in a stable sequence. + * fetching each manifest and its file listing through react-query's cache + * (one cached request of each kind per asset). The returned maps are keyed + * by `refKey(ref)` and enumerated in discovery order so callers can render + * groups in a stable sequence. * * Duplicate refs (same type/org/name/version) are visited once. Dependencies * whose `version` is missing are skipped — the UI can only render what the * download pipeline would actually fetch. */ export function useManifestGraph(refs: AssetManifestRef[]): ManifestGraphState { - const { octokit } = useSession(); + const { api } = useSession(); const queryClient = useQueryClient(); const refsKey = useMemo(() => refs.map(refKey).sort().join('|'), [refs]); @@ -49,36 +52,48 @@ export function useManifestGraph(refs: AssetManifestRef[]): ManifestGraphState { const [state, setState] = useState({ error: null, + files: new Map(), isLoading: initialRefs.length > 0, manifests: new Map(), order: [], }); useEffect(() => { - if (!octokit || initialRefs.length === 0) { - setState({ error: null, isLoading: false, manifests: new Map(), order: [] }); + if (!api || initialRefs.length === 0) { + setState({ error: null, files: new Map(), isLoading: false, manifests: new Map(), order: [] }); return; } let cancelled = false; - setState({ error: null, isLoading: true, manifests: new Map(), order: [] }); + setState({ error: null, files: new Map(), isLoading: true, manifests: new Map(), order: [] }); const seen = new Set(initialRefs.map(refKey)); const queue: AssetManifestRef[] = [...initialRefs]; const order: string[] = []; const manifests = new Map(); + const files = new Map(); (async () => { while (queue.length > 0) { const ref = queue.shift()!; const key = refKey(ref); try { - const manifest = await queryClient.fetchQuery({ - queryFn: ({ signal }) => fetchAssetManifest(ref, { octokit, signal }), - queryKey: queryKeys.assetManifest(ref), - }); + const [manifest, listing] = await Promise.all([ + queryClient.fetchQuery({ + queryFn: ({ signal }) => fetchAssetManifest(ref, { client: api, signal }), + queryKey: queryKeys.assetManifest(ref), + }), + queryClient.fetchQuery({ + queryFn: ({ signal }) => fetchAssetFiles(ref, { client: api, signal }), + queryKey: queryKeys.assetFiles(ref), + }), + ]); if (cancelled) return; manifests.set(key, manifest); + files.set( + key, + listing.files.map((file) => file.path), + ); order.push(key); for (const dep of manifest.dependencies ?? []) { if (!dep.version) continue; @@ -96,6 +111,7 @@ export function useManifestGraph(refs: AssetManifestRef[]): ManifestGraphState { if (cancelled) return; setState({ error: err instanceof Error ? err : new Error(String(err)), + files: new Map(files), isLoading: false, manifests: new Map(manifests), order: [...order], @@ -104,13 +120,13 @@ export function useManifestGraph(refs: AssetManifestRef[]): ManifestGraphState { } } if (cancelled) return; - setState({ error: null, isLoading: false, manifests, order }); + setState({ error: null, files, isLoading: false, manifests, order }); })(); return () => { cancelled = true; }; - }, [octokit, queryClient, initialRefs]); + }, [api, queryClient, initialRefs]); return state; } diff --git a/src/hooks/useRegistry.ts b/src/hooks/useRegistry.ts index 387b6f2..2dcf949 100644 --- a/src/hooks/useRegistry.ts +++ b/src/hooks/useRegistry.ts @@ -4,19 +4,17 @@ import type { Registry } from '@/lib/schemas'; import { useSession } from '@/hooks/useSession'; import { queryKeys } from '@/lib/query-keys'; -import { fetchRegistry, type RegistryClientOptions } from '@/lib/registry-client'; +import { fetchRegistry } from '@/lib/registry-client'; -type RegistryHookOptions = Omit; - -/** Fetch and cache the top-level registry.json. Requires an authenticated session. */ -export function useRegistry(options?: RegistryHookOptions): UseQueryResult { - const { octokit } = useSession(); +/** Fetch and cache the registry index. Requires an authenticated session. */ +export function useRegistry(): UseQueryResult { + const { api } = useSession(); return useQuery({ - enabled: Boolean(octokit), + enabled: Boolean(api), queryFn: ({ signal }) => { - if (!octokit) throw new Error('useRegistry: no authenticated Octokit client available'); - return fetchRegistry({ ...options, octokit, signal }); + if (!api) throw new Error('useRegistry: no authenticated API client available'); + return fetchRegistry({ client: api, signal }); }, queryKey: queryKeys.registry(), }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..51e92c2 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,191 @@ +/** + * Hand-written wrapper around the generated ATK API client (`src/lib/api/`). + * + * Owns three concerns the generated code does not: + * - Base URL resolution from `VITE_ATK_API_URL`. + * - Wiring: bearer auth from the session token and retries through + * {@link fetchWithRetry}. + * - Error mapping: every non-2xx result becomes an {@link ApiRequestError} + * with a stable `code`, an HTTP `status`, and a message the UI can show. + * + * Nothing outside this module may import the generated default client in + * `api/client.gen.ts`; it points at production and carries no auth. + */ + +import type { ApiErrorDetail } from './api/types.gen'; + +import { type Client, createClient } from './api/client'; +import { fetchWithRetry, type RetryOptions } from './fetch-retry'; + +export type { ApiErrorDetail } from './api/types.gen'; + +/** A configured ATK API client: base URL, bearer auth, and retries wired in. */ +export type ApiClient = Client; + +/** Shape of a generated SDK call result with `throwOnError: false` and `responseStyle: 'fields'`. */ +export interface ApiResult { + data?: T; + error?: unknown; + response?: Response; +} + +/** Options for {@link createApiClient}. */ +export interface CreateApiClientOptions { + /** Override retry behaviour (tests use fast backoff). */ + retry?: RetryOptions; +} + +/** The API's error envelope, as returned by every non-2xx response. */ +interface ApiErrorEnvelope { + details?: ApiErrorDetail[] | null; + error: string; + message: string; +} + +/** + * Thrown by {@link unwrap} for any failed ATK API call. + * + * `status` is the HTTP status (0 for a network failure). `code` is the API's + * machine-readable error code (`not_found`, `version_not_bumped`, ...), or a + * synthetic one (`network_error`, `http_error`) when the API did not send an + * envelope. `details` carries per-item validation problems when present. + */ +export class ApiRequestError extends Error { + readonly code: string; + readonly details?: ApiErrorDetail[]; + readonly resource: string; + readonly status: number; + + constructor( + message: string, + options: { cause?: unknown; code: string; details?: ApiErrorDetail[]; resource: string; status: number }, + ) { + super(message, options.cause !== undefined ? { cause: options.cause } : undefined); + this.name = 'ApiRequestError'; + this.code = options.code; + this.details = options.details; + this.resource = options.resource; + this.status = options.status; + } +} + +/** Codes the API uses for a 403 that means "signed in, but not an org member". */ +export const NOT_MEMBER_CODES = new Set(['not_org_member', 'org_membership_unverifiable']); + +/** + * Build an ATK API client for a session token. + * + * `token` may be `null` for the one unauthenticated call (the OAuth code + * exchange); the bearer header is simply omitted. Every request is routed + * through {@link fetchWithRetry}, so 429/5xx responses and network errors are + * retried with backoff before the caller sees them. + */ +export function createApiClient(token: null | string, options: CreateApiClientOptions = {}): ApiClient { + return createClient({ + auth: () => token ?? undefined, + baseUrl: getApiUrl(), + fetch: (input: Request | string | URL, init?: RequestInit) => fetchWithRetry(input, init, options.retry), + throwOnError: false, + }); +} + +/** Read the ATK API base URL from the Vite environment (trailing slash stripped). */ +export function getApiUrl(): string { + const value = import.meta.env.VITE_ATK_API_URL; + if (!value) { + throw new Error('VITE_ATK_API_URL is not set. Copy .env.example to .env.local and fill in the ATK API URL.'); + } + return value.replace(/\/+$/, ''); +} + +/** + * Type guard for {@link ApiRequestError}, optionally narrowed by API error + * code and/or HTTP status. + */ +export function isApiError(err: unknown, code?: string, status?: number): err is ApiRequestError { + if (!(err instanceof ApiRequestError)) return false; + if (code !== undefined && err.code !== code) return false; + if (status !== undefined && err.status !== status) return false; + return true; +} + +/** + * Convert a generated-client result into its `data`, or throw an + * {@link ApiRequestError} describing why the call failed. + * + * @param result - The `{ data, error, response }` object returned by an SDK function + * @param resource - Human-readable name of what was requested (used in messages) + */ +export function unwrap(result: ApiResult, resource: string): T { + const { data, error, response } = result; + + if (response === undefined) { + // The fetch itself threw (offline, DNS, CORS, abort, ...). + const cause = error instanceof Error ? error.message : error ? String(error) : 'Network request failed'; + throw new ApiRequestError(`Could not reach the ATK API while loading ${resource}: ${cause}`, { + cause: error, + code: 'network_error', + resource, + status: 0, + }); + } + + if (response.ok && error === undefined) { + return data as T; + } + + throw toApiRequestError(response.status, error, resource); +} + +function capitalize(text: string): string { + return text.length === 0 ? text : text[0]!.toUpperCase() + text.slice(1); +} + +function isErrorEnvelope(value: unknown): value is ApiErrorEnvelope { + return ( + typeof value === 'object' && + value !== null && + typeof (value as ApiErrorEnvelope).error === 'string' && + typeof (value as ApiErrorEnvelope).message === 'string' + ); +} + +/** + * Map an HTTP status plus the API's error envelope (when present) to an + * {@link ApiRequestError} with a message suitable for a toast or banner. + */ +function toApiRequestError(status: number, error: unknown, resource: string): ApiRequestError { + const envelope = isErrorEnvelope(error) ? error : undefined; + const code = envelope?.error ?? 'http_error'; + const apiMessage = envelope?.message ?? (typeof error === 'string' && error ? error : undefined); + const details = envelope?.details ?? undefined; + const make = (message: string) => new ApiRequestError(message, { code, details, resource, status }); + + if (status === 401) { + return make('Your GitHub session is no longer valid. Sign in again.'); + } + + if (status === 403 && NOT_MEMBER_CODES.has(code)) { + return make( + 'Your GitHub account is not an active member of the EmergentSoftware organization, or membership could not be verified.', + ); + } + + if (status === 404) { + return make(apiMessage ?? `${capitalize(resource)} was not found.`); + } + + if (status === 429) { + return make('The ATK API is rate limiting requests. Wait a minute and try again.'); + } + + if (status >= 500) { + const detail = apiMessage ? ` (${code}: ${apiMessage})` : ''; + return make( + `The ATK API is unavailable while loading ${resource}: HTTP ${status}${detail}. Try again in a moment.`, + ); + } + + // 400/403/409 and anything else: the API message is the best text we have. + return make(apiMessage ?? `Loading ${resource} failed with HTTP ${status}.`); +} diff --git a/src/lib/api/client.gen.ts b/src/lib/api/client.gen.ts new file mode 100644 index 0000000..23eefab --- /dev/null +++ b/src/lib/api/client.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client: Client = createClient(createConfig({ baseUrl: 'https://func-atk-prod.azurewebsites.net' })); diff --git a/src/lib/api/client/client.gen.ts b/src/lib/api/client/client.gen.ts new file mode 100644 index 0000000..fc3f037 --- /dev/null +++ b/src/lib/api/client/client.gen.ts @@ -0,0 +1,277 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors(); + + const beforeRequest = async < + TData = unknown, + TResponseStyle extends 'data' | 'fields' = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, + >( + options: RequestOptions, + ) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined as string | undefined, + }; + + if (opts.security) { + await setAuthParams(opts); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined; + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const resolvedOpts = opts as typeof opts & + ResolvedRequestOptions; + const url = buildUrl(resolvedOpts); + + return { opts: resolvedOpts, url }; + }; + + const request: Client['request'] = async (options) => { + const throwOnError = options.throwOnError ?? _config.throwOnError; + const responseStyle = options.responseStyle ?? _config.responseStyle; + + let request: Request | undefined; + let response: Response | undefined; + + try { + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + + response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if (response.status === 204 || response.headers.get('Content-Length') === '0') { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'text': + data = await response[parseAs](); + break; + case 'json': { + // Some servers return 200 with no Content-Length and empty body. + // response.json() would throw; read as text and parse if non-empty. + const text = await response.text(); + data = text ? JSON.parse(text) : {}; + break; + } + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError; + } catch (error) { + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = await fn(finalError, response, request, options as ResolvedRequestOptions); + } + } + + finalError = finalError || {}; + + if (throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return responseStyle === 'data' + ? undefined + : { + error: finalError, + request, + response, + }; + } + }; + + const makeMethodFn = (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, + url, + }); + }; + + const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options }); + + return { + buildUrl: _buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/src/lib/api/client/index.ts b/src/lib/api/client/index.ts new file mode 100644 index 0000000..8c69331 --- /dev/null +++ b/src/lib/api/client/index.ts @@ -0,0 +1,27 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export type { ServerSentEventsResult } from '../core/serverSentEvents.gen'; +export type { ClientMeta } from '../core/types.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/src/lib/api/client/types.gen.ts b/src/lib/api/client/types.gen.ts new file mode 100644 index 0000000..193646c --- /dev/null +++ b/src/lib/api/client/types.gen.ts @@ -0,0 +1,218 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> + extends + Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onRequest' + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + headers: Headers; + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? (TData extends Record ? TData[keyof TData] : TData) | undefined + : ( + | { + data: TData extends Record ? TData[keyof TData] : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record ? TError[keyof TError] : TError; + } + ) & { + /** request may be undefined, because error may be from building the request object itself */ + request?: Request; + /** response may be undefined, because error may be from building the request object itself or from a network error */ + response?: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick>, 'method'>, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: TData & Options, +) => string; + +export type Client = CoreClient & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + ([TData] extends [never] ? unknown : Omit); diff --git a/src/lib/api/client/utils.gen.ts b/src/lib/api/client/utils.gen.ts new file mode 100644 index 0000000..d4a7284 --- /dev/null +++ b/src/lib/api/client/utils.gen.ts @@ -0,0 +1,316 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = (contentType: string | null): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type)) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export async function setAuthParams( + options: Pick & { + headers: Headers; + }, +): Promise { + for (const auth of options.security ?? []) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +} + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e., their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + /** response may be undefined due to a network error where no response object is produced */ + response: Res | undefined, + /** request may be undefined, because error may be from building the request object itself */ + request: Req | undefined, + options: Options, +) => Err | Promise; + +type ReqInterceptor = (request: Req, options: Options) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/src/lib/api/core/auth.gen.ts b/src/lib/api/core/auth.gen.ts new file mode 100644 index 0000000..c663664 --- /dev/null +++ b/src/lib/api/core/auth.gen.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/src/lib/api/core/bodySerializer.gen.ts b/src/lib/api/core/bodySerializer.gen.ts new file mode 100644 index 0000000..67daca6 --- /dev/null +++ b/src/lib/api/core/bodySerializer.gen.ts @@ -0,0 +1,82 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: unknown) => unknown; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: (body: unknown): FormData => { + const data = new FormData(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: unknown): string => + JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: (body: unknown): string => { + const data = new URLSearchParams(); + + Object.entries(body as Record).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/src/lib/api/core/params.gen.ts b/src/lib/api/core/params.gen.ts new file mode 100644 index 0000000..5e8908f --- /dev/null +++ b/src/lib/api/core/params.gen.ts @@ -0,0 +1,178 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + } + | { + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If `in` is omitted, `map` aliases `key` to the transport layer. + */ + map: Slot; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + | { + in: Slot; + map?: string; + } + | { + in?: never; + map: Slot; + } +>; + +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if ('key' in config) { + map.set(config.key, { + map: config.map, + }); + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +} + +interface Params { + body?: unknown; + headers: Record; + path: Record; + query: Record; +} + +function stripEmptySlots(params: Params): void { + for (const [slot, value] of Object.entries(params)) { + if (slot === 'body') continue; + if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +} + +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: Params = { + headers: Object.create(null), + path: Object.create(null), + query: Object.create(null), + }; + + const map = buildKeyMap(fields); + + function writeSlot(slot: Slot, key: string, value: unknown): void { + let record = params[slot] as Record | undefined; + if (record === undefined) { + record = Object.create(null) as Record; + params[slot] = record; + } + record[key] = value; + } + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + if (field.in) { + writeSlot(field.in, name, arg); + } + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + if (field.in) { + const name = field.map || key; + writeSlot(field.in, name, value); + } else { + params[field.map] = value; + } + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)); + + if (extra) { + const [prefix, slot] = extra; + writeSlot(slot, key.slice(prefix.length), value); + } else if ('allowExtra' in config && config.allowExtra) { + for (const [slot, allowed] of Object.entries(config.allowExtra)) { + if (allowed) { + writeSlot(slot as Slot, key, value); + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +} diff --git a/src/lib/api/core/pathSerializer.gen.ts b/src/lib/api/core/pathSerializer.gen.ts new file mode 100644 index 0000000..fab1ed4 --- /dev/null +++ b/src/lib/api/core/pathSerializer.gen.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}): string => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam): string => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}): string => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues; +}; diff --git a/src/lib/api/core/queryKeySerializer.gen.ts b/src/lib/api/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..773b065 --- /dev/null +++ b/src/lib/api/core/queryKeySerializer.gen.ts @@ -0,0 +1,117 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => { + if (value === null) { + return null; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/src/lib/api/core/serverSentEvents.gen.ts b/src/lib/api/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..ddf3c4d --- /dev/null +++ b/src/lib/api/core/serverSentEvents.gen.ts @@ -0,0 +1,242 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export function createSseClient({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult { + let lastEventId: string | undefined; + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +} diff --git a/src/lib/api/core/types.gen.ts b/src/lib/api/core/types.gen.ts new file mode 100644 index 0000000..c657c85 --- /dev/null +++ b/src/lib/api/core/types.gen.ts @@ -0,0 +1,110 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + string | number | boolean | (string | number | boolean)[] | null | undefined | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g., converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K]; +}; diff --git a/src/lib/api/core/utils.gen.ts b/src/lib/api/core/utils.gen.ts new file mode 100644 index 0000000..af56e07 --- /dev/null +++ b/src/lib/api/core/utils.gen.ts @@ -0,0 +1,140 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}): string => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}): unknown { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e., client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..179ab7f --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export { authGitHubExchange, checkout, downloadAsset, downloadBundle, getAssetFile, getAssetManifest, getAssetReadme, getBundleManifest, getBundleReadme, getRegistry, health, listAssetFiles, me, openApi, type Options, publish, publishPlan } from './sdk.gen'; +export type { ApiError, ApiErrorDetail, AuthGitHubExchangeData, AuthGitHubExchangeError, AuthGitHubExchangeErrors, AuthGitHubExchangeResponse, AuthGitHubExchangeResponses, CheckoutData, CheckoutError, CheckoutErrors, CheckoutFile, CheckoutResponse, CheckoutResponse2, CheckoutResponses, ClientOptions, DownloadAssetData, DownloadAssetError, DownloadAssetErrors, DownloadAssetResponse, DownloadAssetResponses, DownloadBundleData, DownloadBundleError, DownloadBundleErrors, DownloadBundleResponse, DownloadBundleResponses, FileEntry, FileListResponse, GetAssetFileData, GetAssetFileError, GetAssetFileErrors, GetAssetFileResponse, GetAssetFileResponses, GetAssetManifestData, GetAssetManifestError, GetAssetManifestErrors, GetAssetManifestResponse, GetAssetManifestResponses, GetAssetReadmeData, GetAssetReadmeError, GetAssetReadmeErrors, GetAssetReadmeResponse, GetAssetReadmeResponses, GetBundleManifestData, GetBundleManifestError, GetBundleManifestErrors, GetBundleManifestResponse, GetBundleManifestResponses, GetBundleReadmeData, GetBundleReadmeError, GetBundleReadmeErrors, GetBundleReadmeResponse, GetBundleReadmeResponses, GetRegistryData, GetRegistryError, GetRegistryErrors, GetRegistryResponse, GetRegistryResponses, HealthData, HealthResponse, HealthResponse2, HealthResponses, ListAssetFilesData, ListAssetFilesError, ListAssetFilesErrors, ListAssetFilesResponse, ListAssetFilesResponses, MeData, MeError, MeErrors, MeResponse, MeResponses, OAuthExchangeRequest, OAuthTokenResponse, OpenApiData, OpenApiResponse, OpenApiResponses, Principal, PublishData, PublishError, PublishErrors, PublishFile, PublishPlan, PublishPlanData, PublishPlanError, PublishPlanErrors, PublishPlanResponse, PublishPlanResponses, PublishRequest, PublishResponse, PublishResponse2, PublishResponses } from './types.gen'; diff --git a/src/lib/api/sdk.gen.ts b/src/lib/api/sdk.gen.ts new file mode 100644 index 0000000..50b5498 --- /dev/null +++ b/src/lib/api/sdk.gen.ts @@ -0,0 +1,166 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; +import { client } from './client.gen'; +import type { AuthGitHubExchangeData, AuthGitHubExchangeErrors, AuthGitHubExchangeResponses, CheckoutData, CheckoutErrors, CheckoutResponses, DownloadAssetData, DownloadAssetErrors, DownloadAssetResponses, DownloadBundleData, DownloadBundleErrors, DownloadBundleResponses, GetAssetFileData, GetAssetFileErrors, GetAssetFileResponses, GetAssetManifestData, GetAssetManifestErrors, GetAssetManifestResponses, GetAssetReadmeData, GetAssetReadmeErrors, GetAssetReadmeResponses, GetBundleManifestData, GetBundleManifestErrors, GetBundleManifestResponses, GetBundleReadmeData, GetBundleReadmeErrors, GetBundleReadmeResponses, GetRegistryData, GetRegistryErrors, GetRegistryResponses, HealthData, HealthResponses, ListAssetFilesData, ListAssetFilesErrors, ListAssetFilesResponses, MeData, MeErrors, MeResponses, OpenApiData, OpenApiResponses, PublishData, PublishErrors, PublishPlanData, PublishPlanErrors, PublishPlanResponses, PublishResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: keyof ClientMeta extends never ? Record : ClientMeta; +}; + +/** + * Liveness probe + */ +export const health = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/health', ...options }); + +/** + * This document + */ +export const openApi = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/openapi.json', ...options }); + +/** + * Exchange a GitHub OAuth authorization code for an access token (web sign-in). GitHub's response body is returned verbatim. + */ +export const authGitHubExchange = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/auth/github/exchange', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * The authenticated caller as resolved by the API + */ +export const me = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/me', + ...options +}); + +/** + * The registry index (`registry.json`) at the current commit. Supports `If-None-Match` with the `ETag` returned. + */ +export const getRegistry = (options?: Options): RequestResult => (options?.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/registry', + ...options +}); + +/** + * The asset version's `manifest.json` + */ +export const getAssetManifest = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/assets/{type}/{name}/{version}/manifest', + ...options +}); + +/** + * The asset version's `README.md` + */ +export const getAssetReadme = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/assets/{type}/{name}/{version}/readme', + ...options +}); + +/** + * Every file in the asset version directory (manifest and README included) + */ +export const listAssetFiles = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/assets/{type}/{name}/{version}/files', + ...options +}); + +/** + * Raw bytes of one file in the asset version directory (path from the `files` listing) + */ +export const getAssetFile = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/assets/{type}/{name}/{version}/file/{path}', + ...options +}); + +/** + * Zip of the asset and its transitive dependencies. Layout: `{name}/…` plus `dependencies/{dep}/…`. `format=skill` omits the top-level manifest and README and uses the `.skill` extension. + */ +export const downloadAsset = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/assets/{type}/{name}/{version}/download', + ...options +}); + +/** + * The bundle version's `bundle.json` + */ +export const getBundleManifest = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/bundles/{name}/{version}/manifest', + ...options +}); + +/** + * The bundle version's `README.md` + */ +export const getBundleReadme = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/bundles/{name}/{version}/readme', + ...options +}); + +/** + * Zip of the bundle: `bundle.json` at the root and each member under `{member}/…`. `format=skill` drops `bundle.json` and nests skill members as `{member}.skill`. + */ +export const downloadBundle = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/bundles/{name}/{version}/download', + ...options +}); + +/** + * Everything `atk checkout` needs in one call: the asset resolved by name, with every file base64 encoded. + */ +export const checkout = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/checkout/{name}', + ...options +}); + +/** + * Validate a publish payload and return the plan (branch, path, PR title and body, files, reviewers, warnings) without touching GitHub. + */ +export const publishPlan = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/publish/plan', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Validate, then create the branch, commit and pull request on the registry with the caller's own token so the PR is authored by them. Global targets get the default reviewers; org targets use `reviewers`. + */ +export const publish = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/publish', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); diff --git a/src/lib/api/types.gen.ts b/src/lib/api/types.gen.ts new file mode 100644 index 0000000..ac748d2 --- /dev/null +++ b/src/lib/api/types.gen.ts @@ -0,0 +1,1015 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'https://func-atk-prod.azurewebsites.net' | 'https://func-atk-dev.azurewebsites.net' | 'http://localhost:7071' | (string & {}); +}; + +/** + * Error envelope + */ +export type ApiError = { + /** + * Machine-readable error code + */ + error: string; + /** + * Human-readable message + */ + message: string; + /** + * Per-item problems for validation failures + */ + details?: Array | null; +}; + +/** + * One problem + */ +export type ApiErrorDetail = { + /** + * What is wrong + */ + message: string; + /** + * JSON pointer into the payload, when applicable + */ + path?: string | null; +}; + +/** + * One file with content + */ +export type CheckoutFile = { + /** + * Path relative to the version directory + */ + path: string; + /** + * Always `base64` + */ + encoding: string; + /** + * Base64 bytes + */ + content: string; + /** + * Decoded size in bytes + */ + size: number; +}; + +/** + * A working copy of one asset version + */ +export type CheckoutResponse = { + /** + * Asset name + */ + name: string; + /** + * Org scope + */ + org?: string | null; + /** + * Asset type + */ + type: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Resolved version + */ + version: string; + /** + * Every file including manifest.json and README.md + */ + files: Array; +}; + +/** + * A file in a version directory + */ +export type FileEntry = { + /** + * Path relative to the version directory + */ + path: string; + /** + * Git blob sha + */ + sha: string; + /** + * Size in bytes + */ + size: number; +}; + +/** + * Directory listing + */ +export type FileListResponse = { + /** + * Asset name + */ + name: string; + /** + * Org scope + */ + org?: string | null; + /** + * Asset type + */ + type: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Resolved version + */ + version: string; + /** + * Files, sorted by path + */ + files: Array; +}; + +/** + * Liveness + */ +export type HealthResponse = { + /** + * Always `ok` + */ + status: string; + /** + * Assembly version + */ + version: string; + /** + * `Development` or `Production` + */ + environment: string; +}; + +/** + * OAuth code exchange + */ +export type OAuthExchangeRequest = { + /** + * Authorization code from the GitHub redirect + */ + code: string; +}; + +/** + * GitHub's token response, forwarded as-is + */ +export type OAuthTokenResponse = { + /** + * The user's GitHub token + */ + access_token: string; + /** + * Granted scopes + */ + scope?: string | null; + /** + * `bearer` + */ + token_type?: string | null; +}; + +/** + * The authenticated caller + */ +export type Principal = { + /** + * Auth scheme that validated the token (`github`) + */ + scheme: string; + /** + * GitHub login + */ + login: string; + /** + * Display name + */ + name?: string | null; + /** + * Avatar URL + */ + avatarUrl?: string | null; +}; + +/** + * A file to commit + */ +export type PublishFile = { + /** + * Path relative to the version directory (no dotfiles, no `..`) + */ + path: string; + /** + * File content, text or base64 per `encoding` + */ + content: string; + /** + * Defaults to `utf8` + */ + encoding?: 'utf8' | 'base64'; +}; + +/** + * What a publish would do + */ +export type PublishPlan = { + /** + * asset or bundle + */ + kind: 'asset' | 'bundle'; + /** + * Name + */ + name: string; + /** + * Org scope + */ + org?: string | null; + /** + * Asset type (assets only) + */ + assetType?: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Version being published + */ + version: string; + /** + * True when the registry already has this asset or bundle + */ + isUpdate: boolean; + /** + * The registry's current latest when isUpdate + */ + previousVersion?: string | null; + /** + * Branch that will be pushed + */ + branchName: string; + /** + * Directory in the registry, with trailing slash + */ + registryPath: string; + /** + * Pull request title + */ + prTitle: string; + /** + * Pull request body (markdown) + */ + prBody: string; + /** + * Files that will be committed, in order + */ + files: Array; + /** + * Reviewers that will be requested + */ + reviewers: Array; + /** + * Non-blocking advice (missing README, tags, unlisted files) + */ + warnings: Array; +}; + +/** + * Publish payload + */ +export type PublishRequest = { + /** + * What is being published + */ + kind: 'asset' | 'bundle'; + /** + * The exact `manifest.json` (kind=asset) or `bundle.json` (kind=bundle) to commit. Validated against the registry's JSON Schema; `files` must be a concrete array. + */ + manifest: { + [key: string]: unknown; + }; + /** + * All other files: entrypoint, listed files, README.md. Bundles may only add README.md. + */ + files?: Array | null; + /** + * Optional notes appended to the PR body + */ + message?: string | null; + /** + * Reviewers to request. Only allowed for org-scoped targets; global targets always get the default reviewers. + */ + reviewers?: Array | null; + /** + * Delete and recreate the publish branch if it already exists (the CLI's retry behaviour). Default false → 409 `branch_exists`. + */ + replaceExistingBranch?: boolean; + /** + * Which client is publishing; only affects the PR footer + */ + client?: 'cli' | 'web' | 'api'; +}; + +/** + * Result of a publish + */ +export type PublishResponse = { + /** + * Pull request URL + */ + prUrl: string; + /** + * Pull request number + */ + prNumber: number; + /** + * Branch pushed + */ + branchName: string; + /** + * Commit created + */ + commitSha: string; + /** + * Reviewers requested (the author is skipped) + */ + reviewers: Array; + /** + * Set when the reviewer request failed; the PR still exists + */ + reviewerWarning?: string | null; + /** + * Non-blocking advice from validation + */ + warnings: Array; +}; + +export type HealthData = { + body?: never; + path?: never; + query?: never; + url: '/health'; +}; + +export type HealthResponses = { + /** + * Service is up + */ + 200: HealthResponse; +}; + +export type HealthResponse2 = HealthResponses[keyof HealthResponses]; + +export type OpenApiData = { + body?: never; + path?: never; + query?: never; + url: '/openapi.json'; +}; + +export type OpenApiResponses = { + /** + * OpenAPI 3.1 document + */ + 200: { + [key: string]: unknown; + }; +}; + +export type OpenApiResponse = OpenApiResponses[keyof OpenApiResponses]; + +export type AuthGitHubExchangeData = { + body: OAuthExchangeRequest; + path?: never; + query?: never; + url: '/auth/github/exchange'; +}; + +export type AuthGitHubExchangeErrors = { + /** + * Missing code, or GitHub rejected it (`error` carries GitHub's OAuth error code) + */ + 400: ApiError; + /** + * OAuth app not configured + */ + 500: ApiError; + /** + * GitHub unreachable or returned an unusable response + */ + 502: ApiError; +}; + +export type AuthGitHubExchangeError = AuthGitHubExchangeErrors[keyof AuthGitHubExchangeErrors]; + +export type AuthGitHubExchangeResponses = { + /** + * GitHub token response + */ + 200: OAuthTokenResponse; +}; + +export type AuthGitHubExchangeResponse = AuthGitHubExchangeResponses[keyof AuthGitHubExchangeResponses]; + +export type MeData = { + body?: never; + path?: never; + query?: never; + url: '/me'; +}; + +export type MeErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; +}; + +export type MeError = MeErrors[keyof MeErrors]; + +export type MeResponses = { + /** + * Principal + */ + 200: Principal; +}; + +export type MeResponse = MeResponses[keyof MeResponses]; + +export type GetRegistryData = { + body?: never; + path?: never; + query?: never; + url: '/registry'; +}; + +export type GetRegistryErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; +}; + +export type GetRegistryError = GetRegistryErrors[keyof GetRegistryErrors]; + +export type GetRegistryResponses = { + /** + * registry.json + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetRegistryResponse = GetRegistryResponses[keyof GetRegistryResponses]; + +export type GetAssetManifestData = { + body?: never; + path: { + /** + * Asset type + */ + type: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Asset name (bare, no @org prefix) + */ + name: string; + /** + * Exact semver version, or `latest` + */ + version: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + }; + url: '/assets/{type}/{name}/{version}/manifest'; +}; + +export type GetAssetManifestErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type GetAssetManifestError = GetAssetManifestErrors[keyof GetAssetManifestErrors]; + +export type GetAssetManifestResponses = { + /** + * manifest.json + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetAssetManifestResponse = GetAssetManifestResponses[keyof GetAssetManifestResponses]; + +export type GetAssetReadmeData = { + body?: never; + path: { + /** + * Asset type + */ + type: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Asset name (bare, no @org prefix) + */ + name: string; + /** + * Exact semver version, or `latest` + */ + version: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + }; + url: '/assets/{type}/{name}/{version}/readme'; +}; + +export type GetAssetReadmeErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type GetAssetReadmeError = GetAssetReadmeErrors[keyof GetAssetReadmeErrors]; + +export type GetAssetReadmeResponses = { + /** + * Markdown + */ + 200: string; +}; + +export type GetAssetReadmeResponse = GetAssetReadmeResponses[keyof GetAssetReadmeResponses]; + +export type ListAssetFilesData = { + body?: never; + path: { + /** + * Asset type + */ + type: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Asset name (bare, no @org prefix) + */ + name: string; + /** + * Exact semver version, or `latest` + */ + version: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + }; + url: '/assets/{type}/{name}/{version}/files'; +}; + +export type ListAssetFilesErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type ListAssetFilesError = ListAssetFilesErrors[keyof ListAssetFilesErrors]; + +export type ListAssetFilesResponses = { + /** + * File list + */ + 200: FileListResponse; +}; + +export type ListAssetFilesResponse = ListAssetFilesResponses[keyof ListAssetFilesResponses]; + +export type GetAssetFileData = { + body?: never; + path: { + /** + * Asset type + */ + type: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Asset name (bare, no @org prefix) + */ + name: string; + /** + * Exact semver version, or `latest` + */ + version: string; + /** + * File path relative to the version directory, e.g. `SKILL.md` or `reference/guide.md` + */ + path: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + }; + url: '/assets/{type}/{name}/{version}/file/{path}'; +}; + +export type GetAssetFileErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type GetAssetFileError = GetAssetFileErrors[keyof GetAssetFileErrors]; + +export type GetAssetFileResponses = { + /** + * File content + */ + 200: Blob | File; +}; + +export type GetAssetFileResponse = GetAssetFileResponses[keyof GetAssetFileResponses]; + +export type DownloadAssetData = { + body?: never; + path: { + /** + * Asset type + */ + type: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Asset name (bare, no @org prefix) + */ + name: string; + /** + * Exact semver version, or `latest` + */ + version: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + /** + * Archive variant + */ + format?: 'zip' | 'skill'; + }; + url: '/assets/{type}/{name}/{version}/download'; +}; + +export type DownloadAssetErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type DownloadAssetError = DownloadAssetErrors[keyof DownloadAssetErrors]; + +export type DownloadAssetResponses = { + /** + * Archive + */ + 200: Blob | File; +}; + +export type DownloadAssetResponse = DownloadAssetResponses[keyof DownloadAssetResponses]; + +export type GetBundleManifestData = { + body?: never; + path: { + /** + * Bundle name (bare, no @org prefix) + */ + name: string; + /** + * Exact version, or `latest` + */ + version: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + }; + url: '/bundles/{name}/{version}/manifest'; +}; + +export type GetBundleManifestErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type GetBundleManifestError = GetBundleManifestErrors[keyof GetBundleManifestErrors]; + +export type GetBundleManifestResponses = { + /** + * bundle.json + */ + 200: { + [key: string]: unknown; + }; +}; + +export type GetBundleManifestResponse = GetBundleManifestResponses[keyof GetBundleManifestResponses]; + +export type GetBundleReadmeData = { + body?: never; + path: { + /** + * Bundle name (bare, no @org prefix) + */ + name: string; + /** + * Exact version, or `latest` + */ + version: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + }; + url: '/bundles/{name}/{version}/readme'; +}; + +export type GetBundleReadmeErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type GetBundleReadmeError = GetBundleReadmeErrors[keyof GetBundleReadmeErrors]; + +export type GetBundleReadmeResponses = { + /** + * Markdown + */ + 200: string; +}; + +export type GetBundleReadmeResponse = GetBundleReadmeResponses[keyof GetBundleReadmeResponses]; + +export type DownloadBundleData = { + body?: never; + path: { + /** + * Bundle name (bare, no @org prefix) + */ + name: string; + /** + * Exact version, or `latest` + */ + version: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + /** + * Archive variant + */ + format?: 'zip' | 'skill'; + }; + url: '/bundles/{name}/{version}/download'; +}; + +export type DownloadBundleErrors = { + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type DownloadBundleError = DownloadBundleErrors[keyof DownloadBundleErrors]; + +export type DownloadBundleResponses = { + /** + * Archive + */ + 200: Blob | File; +}; + +export type DownloadBundleResponse = DownloadBundleResponses[keyof DownloadBundleResponses]; + +export type CheckoutData = { + body?: never; + path: { + /** + * Asset name (bare) + */ + name: string; + }; + query?: { + /** + * Org scope (bare name, no `@`). Omit for global assets. + */ + org?: string; + /** + * Asset type; required only when the name exists as more than one type + */ + type?: 'skill' | 'agent' | 'rule' | 'hook' | 'memory-template' | 'mcp-config'; + /** + * Exact version; defaults to `latest` + */ + version?: string; + }; + url: '/checkout/{name}'; +}; + +export type CheckoutErrors = { + /** + * Ambiguous name (exists as several types) or bad type + */ + 400: ApiError; + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * Unknown asset, version, or file + */ + 404: ApiError; +}; + +export type CheckoutError = CheckoutErrors[keyof CheckoutErrors]; + +export type CheckoutResponses = { + /** + * Working copy + */ + 200: CheckoutResponse; +}; + +export type CheckoutResponse2 = CheckoutResponses[keyof CheckoutResponses]; + +export type PublishPlanData = { + body: PublishRequest; + path?: never; + query?: never; + url: '/publish/plan'; +}; + +export type PublishPlanErrors = { + /** + * Payload failed schema or rule validation (`validation_failed`, `schema_invalid`, `invalid_reviewer`, `reviewers_not_allowed`) + */ + 400: ApiError; + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * `version_not_bumped`, `version_exists`, or `branch_exists` + */ + 409: ApiError; +}; + +export type PublishPlanError = PublishPlanErrors[keyof PublishPlanErrors]; + +export type PublishPlanResponses = { + /** + * Plan + */ + 200: PublishPlan; +}; + +export type PublishPlanResponse = PublishPlanResponses[keyof PublishPlanResponses]; + +export type PublishData = { + body: PublishRequest; + path?: never; + query?: never; + url: '/publish'; +}; + +export type PublishErrors = { + /** + * Payload failed schema or rule validation (`validation_failed`, `schema_invalid`, `invalid_reviewer`, `reviewers_not_allowed`) + */ + 400: ApiError; + /** + * Missing or invalid token + */ + 401: ApiError; + /** + * Not an active EmergentSoftware member, or membership unverifiable (`not_org_member`, `org_membership_unverifiable`) + */ + 403: ApiError; + /** + * `version_not_bumped`, `version_exists`, or `branch_exists` + */ + 409: ApiError; +}; + +export type PublishError = PublishErrors[keyof PublishErrors]; + +export type PublishResponses = { + /** + * Pull request opened + */ + 201: PublishResponse; +}; + +export type PublishResponse2 = PublishResponses[keyof PublishResponses]; diff --git a/src/lib/download-service.ts b/src/lib/download-service.ts index 6253f6e..8095bd8 100644 --- a/src/lib/download-service.ts +++ b/src/lib/download-service.ts @@ -1,28 +1,7 @@ -import JSZip from 'jszip'; - -import type { BundleAssetRef } from './schemas/bundle'; - -import { fetchWithRetry, type RetryOptions } from './fetch-retry'; -import { collectFilePaths } from './file-list'; -import { RegistryFetchError, RegistryNotFoundError, RegistryParseError } from './registry-errors'; -import { - assetPathSegments, - bundlePathSegments, - encodePathSegment, - encodeRegistryPath, -} from './registry-paths'; -import { type AssetType, type Bundle, BundleSchema, type Manifest, ManifestSchema } from './schemas'; - -const DEFAULT_OWNER = 'EmergentSoftware'; -const DEFAULT_REPO = 'agentic-toolkit-registry'; -const GITHUB_API = 'https://api.github.com'; - -/** - * Files stripped from the top-level asset folder when downloading in `.skill` - * format. A `.skill` archive is the same zip minus ATK's own metadata so it - * drops cleanly into a Claude Code skills directory. - */ -const SKILL_EXCLUDED_FILES = new Set(['manifest.json', 'README.md']); +import { downloadAsset as apiDownloadAsset, downloadBundle as apiDownloadBundle } from './api'; +import { type ApiClient, ApiRequestError, type ApiResult, unwrap } from './api-client'; +import { RegistryFetchError, RegistryNotFoundError } from './registry-errors'; +import { type AssetType } from './schemas'; export interface AssetRef { name: string; @@ -32,34 +11,26 @@ export interface AssetRef { } export interface DownloadAssetOptions { + client: ApiClient; /** Archive variant; defaults to `zip`. */ format?: DownloadFormat; - owner?: string; - ref?: string; - repo?: string; - retry?: RetryOptions; signal?: AbortSignal; - token?: string; - /** Injection seams for testing. */ + /** Injection seam for testing. */ triggerDownload?: (blob: Blob, filename: string) => void; } -export interface DownloadBundleOptions extends DownloadAssetOptions { +export interface DownloadBundleOptions { + client: ApiClient; + /** Archive variant; defaults to `zip`. */ + format?: DownloadFormat; /** * Bare org name (no `@`) for org-scoped bundles; omit for global bundles. - * Locates the bundle under `bundles/@{org}/{name}/{version}/bundle.json`. */ org?: string; - /** - * Resolve a version for a bundle member that omits its own `version`. - * Typically wired to the registry's `latest` field. Receives the member ref - * exactly as it appears in `bundle.json`. - */ - resolveVersion?: (member: BundleAssetRef) => string | undefined; - /** - * The bundle's own version, used to locate `bundles/[@{org}/]{name}/{version}/bundle.json`. - * Take it from the registry index entry's `version`. - */ + signal?: AbortSignal; + /** Injection seam for testing. */ + triggerDownload?: (blob: Blob, filename: string) => void; + /** The bundle's own version (or `latest`). Take it from the registry index entry's `version`. */ version: string; } @@ -69,45 +40,26 @@ export interface DownloadBundleOptions extends DownloadAssetOptions { */ export type DownloadFormat = 'skill' | 'zip'; -interface FetchedAssetBundle { - files: Map; - manifest: Manifest; - manifestBytes: Uint8Array; - ref: AssetRef; -} - -interface FetchFileOptions { - tolerateMissing?: boolean; -} - /** - * Fetch an asset and all its transitive dependencies, assemble them into a - * JSZip archive, and trigger a browser download. The primary asset's files sit - * flat at the zip root; each dependency is placed under `dependencies/{name}/`. + * Download an asset (with its transitive dependencies) as one archive built by + * the ATK API, then hand the blob to the browser. Layout: `{name}/…` plus + * `dependencies/{dep}/…`; `format: 'skill'` strips the top-level asset's + * metadata and names the file `{name}-{version}.skill`. */ export async function downloadAsset( ref: AssetRef, - options: DownloadAssetOptions = {}, + options: DownloadAssetOptions, ): Promise<{ blob: Blob; filename: string }> { - const visited = new Map(); - const rootBundle = await fetchAssetBundle(ref, options, visited); const format = options.format ?? 'zip'; - - const zip = new JSZip(); - const rootFolder = zip.folder(ref.name); - if (!rootFolder) throw new Error(`Failed to create zip folder for ${ref.name}`); - addBundleToZip(rootFolder, rootBundle, format === 'skill' ? SKILL_EXCLUDED_FILES : undefined); - - for (const [key, bundle] of visited) { - if (key === refKey(ref)) continue; - const folder = zip.folder(`dependencies/${bundle.ref.name}`); - if (!folder) throw new Error(`Failed to create zip folder for ${bundle.ref.name}`); - addBundleToZip(folder, bundle); - } - - const blob = await zip.generateAsync({ type: 'blob' }); - const ext = format === 'skill' ? 'skill' : 'zip'; - const filename = `${ref.name}-${ref.version}.${ext}`; + const result = await apiDownloadAsset({ + client: options.client, + parseAs: 'blob', + path: { name: ref.name, type: ref.type, version: ref.version }, + query: { format, ...(ref.org ? { org: ref.org } : {}) }, + signal: options.signal, + }); + const blob = unwrapDownload(result, `${ref.type} ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version}`); + const filename = `${ref.name}-${ref.version}.${format === 'skill' ? 'skill' : 'zip'}`; const trigger = options.triggerDownload ?? defaultTriggerDownload; trigger(blob, filename); @@ -116,74 +68,25 @@ export async function downloadAsset( } /** - * Fetch a bundle manifest and every member asset (with transitive dependencies) - * and assemble a single JSZip archive. The bundle's `bundle.json` sits at the - * zip root; each member asset's files are placed flat under `{memberName}/`. - * Transitive dependencies of each member are placed under - * `{memberName}/dependencies/{depName}/`. - * - * Member versions come from the {@link BundleAssetRef}; when a member omits - * `version`, the optional `resolveVersion` callback is consulted (typically - * wired to the registry's `latest`). + * Download a bundle as one archive built by the ATK API: `bundle.json` at the + * root and each member under `{member}/…` (`format: 'skill'` drops + * `bundle.json` and nests skill members as `{member}.skill`). The outer file + * keeps the `.zip` extension in both variants. */ export async function downloadBundle( name: string, options: DownloadBundleOptions, ): Promise<{ blob: Blob; filename: string }> { - const bundle = await fetchBundleManifestForDownload(name, options); const format = options.format ?? 'zip'; - - const zip = new JSZip(); - // The `.skill` variant drops ATK's own bundle metadata so the archive contains - // only drop-in assets. - if (format !== 'skill') { - zip.file('bundle.json', `${JSON.stringify(bundle, null, 2)}\n`); - } - - for (const member of bundle.assets) { - const version = resolveMemberVersion(member, options); - if (!version) { - // An org-scoped member that resolves to no version means no asset exists - // in that scope (audit W4) — report the scope, not a generic version miss. - throw new Error( - member.org - ? `Bundle member '${member.name}' (${member.type}) not found in org '${member.org}'.` - : `Bundle member ${member.type}:${member.name} is missing a version and no resolver provided one.`, - ); - } - const memberRef: AssetRef = { name: member.name, org: member.org, type: member.type, version }; - - // In `.skill` format, skill members become their own nested `.skill` archive - // so each one drops cleanly into a skills directory. - if (format === 'skill' && member.type === 'skill') { - const { blob } = await downloadAsset(memberRef, { - ...options, - format: 'skill', - triggerDownload: () => {}, - }); - zip.file(`${member.name}.skill`, blob); - continue; - } - - const visited = new Map(); - const rootBundle = await fetchAssetBundle(memberRef, options, visited); - - const memberFolder = zip.folder(member.name); - if (!memberFolder) throw new Error(`Failed to create zip folder for ${member.name}`); - // Strip the member's own metadata in `.skill` format; dependency folders keep - // their manifests (matching downloadAsset's `.skill` behavior). - addBundleToZip(memberFolder, rootBundle, format === 'skill' ? SKILL_EXCLUDED_FILES : undefined); - - for (const [key, dep] of visited) { - if (key === refKey(memberRef)) continue; - const depFolder = memberFolder.folder(`dependencies/${dep.ref.name}`); - if (!depFolder) throw new Error(`Failed to create zip folder for ${dep.ref.name}`); - addBundleToZip(depFolder, dep); - } - } - - const blob = await zip.generateAsync({ type: 'blob' }); - const filename = `${bundle.name}-${bundle.version}.zip`; + const result = await apiDownloadBundle({ + client: options.client, + parseAs: 'blob', + path: { name, version: options.version }, + query: { format, ...(options.org ? { org: options.org } : {}) }, + signal: options.signal, + }); + const blob = unwrapDownload(result, `bundle ${options.org ? `@${options.org}/` : ''}${name}@${options.version}`); + const filename = `${name}-${options.version}.zip`; const trigger = options.triggerDownload ?? defaultTriggerDownload; trigger(blob, filename); @@ -191,41 +94,6 @@ export async function downloadBundle( return { blob, filename }; } -function addBundleToZip(zip: JSZip, bundle: FetchedAssetBundle, exclude?: Set): void { - for (const [path, bytes] of bundle.files) { - if (exclude?.has(path)) continue; - zip.file(path, bytes); - } -} - -function buildBundleManifestUrl(name: string, options: DownloadBundleOptions): string { - return buildContentsUrl( - bundlePathSegments({ name, org: options.org, version: options.version }), - options, - ); -} - -/** Compose a GitHub Contents API URL from raw registry path segments. */ -function buildContentsUrl(segments: string[], options: DownloadAssetOptions): string { - const owner = options.owner ?? DEFAULT_OWNER; - const repo = options.repo ?? DEFAULT_REPO; - const path = encodeRegistryPath(segments); - const base = `${GITHUB_API}/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/contents/${path}`; - return options.ref ? `${base}?ref=${encodeURIComponent(options.ref)}` : base; -} - -function buildFileUrl(ref: AssetRef, relativePath: string, options: DownloadAssetOptions): string { - return buildContentsUrl(assetPathSegments(ref, relativePath), options); -} - -function decodeBase64(encoded: string): Uint8Array { - const sanitized = encoded.replace(/\s+/g, ''); - const binary = atob(sanitized); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - function defaultTriggerDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); @@ -237,214 +105,20 @@ function defaultTriggerDownload(blob: Blob, filename: string): void { URL.revokeObjectURL(url); } -async function fetchAssetBundle( - ref: AssetRef, - options: DownloadAssetOptions, - visited: Map, -): Promise { - const key = refKey(ref); - const existing = visited.get(key); - if (existing) return existing; - - const placeholder = {} as FetchedAssetBundle; - visited.set(key, placeholder); - - const manifestBytes = await fetchFileBytes(ref, 'manifest.json', options); - const manifestUrl = buildFileUrl(ref, 'manifest.json', options); - const manifestText = new TextDecoder().decode(manifestBytes); - let parsed: unknown; +function unwrapDownload(result: ApiResult, resource: string): Blob { try { - parsed = JSON.parse(manifestText); - } catch (cause) { - throw new RegistryParseError(`Asset manifest is not valid JSON: ${manifestUrl}`, { - cause, - payload: manifestText, - url: manifestUrl, - }); - } - const result = ManifestSchema.safeParse(parsed); - if (!result.success) { - throw new RegistryParseError(`Asset manifest failed schema validation: ${manifestUrl}`, { - payload: manifestText, - url: manifestUrl, - zodError: result.error, - }); - } - const manifest = result.data; - - const files = new Map(); - files.set('manifest.json', manifestBytes); - - const extraPaths = collectFilePaths(manifest); - - for (const path of extraPaths) { - const bytes = await fetchFileBytes(ref, path, options, { tolerateMissing: path === 'README.md' }); - if (bytes) files.set(path, bytes); - } - - const bundle: FetchedAssetBundle = { files, manifest, manifestBytes, ref }; - visited.set(key, bundle); - - if (manifest.dependencies && manifest.dependencies.length > 0) { - for (const dep of manifest.dependencies) { - if (!dep.version) { - throw new Error( - `Dependency ${dep.type}:${dep.name} is missing a version — an explicit version is required for download.`, - ); + return unwrap(result, resource); + } catch (error) { + if (error instanceof ApiRequestError) { + if (error.status === 404) { + throw new RegistryNotFoundError(`Registry resource not found: ${resource}`, { url: resource }); } - const depRef: AssetRef = { name: dep.name, type: dep.type, version: dep.version }; - await fetchAssetBundle(depRef, options, visited); + throw new RegistryFetchError(error.message, { + cause: error, + status: error.status || undefined, + url: resource, + }); } + throw error; } - - return bundle; -} -async function fetchBundleManifestForDownload( - name: string, - options: DownloadBundleOptions, -): Promise { - const url = buildBundleManifestUrl(name, options); - const bytes = await fetchFileBytesRaw(url, options); - const text = new TextDecoder().decode(bytes); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (cause) { - throw new RegistryParseError(`Bundle manifest is not valid JSON: ${url}`, { - cause, - payload: text, - url, - }); - } - const result = BundleSchema.safeParse(parsed); - if (!result.success) { - throw new RegistryParseError(`Bundle manifest failed schema validation: ${url}`, { - payload: text, - url, - zodError: result.error, - }); - } - return result.data; -} - -async function fetchFileBytes( - ref: AssetRef, - relativePath: string, - options: DownloadAssetOptions, - fileOptions?: FetchFileOptions, -): Promise; -async function fetchFileBytes( - ref: AssetRef, - relativePath: string, - options: DownloadAssetOptions, - fileOptions: { tolerateMissing: true }, -): Promise; -async function fetchFileBytes( - ref: AssetRef, - relativePath: string, - options: DownloadAssetOptions, - fileOptions: FetchFileOptions = {}, -): Promise { - const url = buildFileUrl(ref, relativePath, options); - const headers = new Headers({ - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }); - if (options.token) headers.set('Authorization', `Bearer ${options.token}`); - - let response: Response; - try { - response = await fetchWithRetry(url, { headers, signal: options.signal }, options.retry); - } catch (cause) { - if (options.signal?.aborted) throw cause; - throw new RegistryFetchError(`Network error while fetching ${url}`, { cause, url }); - } - - if (response.status === 404) { - if (fileOptions.tolerateMissing) return null; - throw new RegistryNotFoundError(`Asset file not found: ${url}`, { url }); - } - - if (!response.ok) { - throw new RegistryFetchError(`Registry request failed with HTTP ${response.status}`, { - status: response.status, - url, - }); - } - - const rawBody = await response.text(); - let envelope: { content?: string; encoding?: string }; - try { - envelope = JSON.parse(rawBody) as { content?: string; encoding?: string }; - } catch (cause) { - throw new RegistryParseError(`Registry response is not valid JSON: ${url}`, { - cause, - payload: rawBody, - url, - }); - } - - if (!envelope.content || envelope.encoding !== 'base64') { - throw new RegistryParseError(`Registry response is missing decodable base64 content: ${url}`, { - payload: rawBody, - url, - }); - } - - return decodeBase64(envelope.content); -} -async function fetchFileBytesRaw(url: string, options: DownloadBundleOptions): Promise { - const headers = new Headers({ - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - }); - if (options.token) headers.set('Authorization', `Bearer ${options.token}`); - - let response: Response; - try { - response = await fetchWithRetry(url, { headers, signal: options.signal }, options.retry); - } catch (cause) { - if (options.signal?.aborted) throw cause; - throw new RegistryFetchError(`Network error while fetching ${url}`, { cause, url }); - } - - if (response.status === 404) { - throw new RegistryNotFoundError(`Bundle manifest not found: ${url}`, { url }); - } - if (!response.ok) { - throw new RegistryFetchError(`Registry request failed with HTTP ${response.status}`, { - status: response.status, - url, - }); - } - - const rawBody = await response.text(); - let envelope: { content?: string; encoding?: string }; - try { - envelope = JSON.parse(rawBody) as { content?: string; encoding?: string }; - } catch (cause) { - throw new RegistryParseError(`Registry response is not valid JSON: ${url}`, { - cause, - payload: rawBody, - url, - }); - } - if (!envelope.content || envelope.encoding !== 'base64') { - throw new RegistryParseError(`Registry response is missing decodable base64 content: ${url}`, { - payload: rawBody, - url, - }); - } - return decodeBase64(envelope.content); -} -function refKey(ref: AssetRef): string { - return `${ref.type}:${ref.org ?? ''}:${ref.name}:${ref.version}`; -} - -function resolveMemberVersion( - member: BundleAssetRef, - options: DownloadBundleOptions, -): string | undefined { - if (member.version) return member.version; - return options.resolveVersion?.(member); } diff --git a/src/lib/fetch-retry.ts b/src/lib/fetch-retry.ts index b3d3196..b4b182f 100644 --- a/src/lib/fetch-retry.ts +++ b/src/lib/fetch-retry.ts @@ -28,41 +28,6 @@ const DEFAULT_OPTIONS: Required = { /** HTTP status codes that indicate a transient/retryable failure. */ const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); -/** - * Retry an arbitrary async operation (e.g. an Octokit call) on transient failures. - * - * Mirrors the `fetchWithRetry` policy: retries on retryable HTTP status codes - * (read from the thrown error's `status` field) and on network errors that do - * not expose a status. Non-retryable errors (e.g. 401, 403, 404) propagate - * immediately without retry. Honors an optional AbortSignal for cancellation. - */ -export async function callWithRetry( - fn: () => Promise, - retryOptions?: RetryOptions, - signal?: AbortSignal, -): Promise { - const options: Required = { ...DEFAULT_OPTIONS, ...retryOptions }; - - for (let attempt = 0; attempt <= options.maxRetries; attempt++) { - try { - return await fn(); - } catch (error: unknown) { - if (signal?.aborted) throw error; - - const status = (error as { status?: number }).status; - const isRetryable = status === undefined || isRetryableStatus(status); - - if (!isRetryable || attempt >= options.maxRetries) throw error; - - const delay = computeDelay(attempt, options); - await sleep(delay, signal); - } - } - - // Unreachable — the loop either returns or throws. - throw new Error('callWithRetry: exhausted retries without throwing'); -} - /** * Fetch with automatic retry for transient failures. * @@ -73,25 +38,29 @@ export async function callWithRetry( * Non-retryable responses (e.g. 400, 401, 403, 404) are returned immediately * without retry. * - * @param url - The URL to fetch + * Accepts a `Request` as well as a URL so it can be plugged in as the `fetch` + * implementation of the generated ATK API client. A `Request` is cloned per + * attempt because its body stream can only be read once. + * + * @param input - The URL or Request to fetch * @param init - Standard fetch RequestInit options * @param retryOptions - Configurable retry behavior * @returns The fetch Response * @throws The last network error if all retry attempts fail */ export async function fetchWithRetry( - url: string | URL, + input: Request | string | URL, init?: RequestInit, retryOptions?: RetryOptions, ): Promise { const options: Required = { ...DEFAULT_OPTIONS, ...retryOptions }; - const signal = init?.signal ?? null; + const signal = init?.signal ?? (isRequest(input) ? input.signal : null); let lastError: unknown; for (let attempt = 0; attempt <= options.maxRetries; attempt++) { try { - const response = await fetch(url, init); + const response = await fetch(isRequest(input) ? input.clone() : input, init); // Non-retryable status — return immediately if (response.ok || !isRetryableStatus(response.status)) { @@ -161,6 +130,11 @@ function computeDelay(attempt: number, options: Required): number return Math.min(jitterFactor * cappedDelay, options.maxDelayMs); } +/** `Request` is absent in some test environments, so guard the instanceof. */ +function isRequest(input: unknown): input is Request { + return typeof Request !== 'undefined' && input instanceof Request; +} + /** * Parse the Retry-After header value from a response. * diff --git a/src/lib/file-list.ts b/src/lib/file-list.ts deleted file mode 100644 index 81ed38a..0000000 --- a/src/lib/file-list.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Manifest } from './schemas'; - -/** - * Paths fetched in addition to `manifest.json` when assembling an asset's zip. - * The primary download loop in `download-service.ts` iterates this set and - * tolerates a missing README.md; `manifest.json` is excluded because it is - * fetched up front to drive the rest of the download. - */ -export function collectFilePaths(manifest: Manifest): string[] { - const paths = new Set(); - if (manifest.entrypoint) paths.add(manifest.entrypoint); - paths.add('README.md'); - if (manifest.files) for (const p of manifest.files) paths.add(p); - paths.delete('manifest.json'); - return [...paths]; -} - -/** - * Every path that ends up in the zip for a single asset: `manifest.json` plus - * the extras returned by {@link collectFilePaths}. Used by the UI so the - * "Files" card always reflects exactly what the Download button produces. - */ -export function listAssetFiles(manifest: Manifest): string[] { - return ['manifest.json', ...collectFilePaths(manifest)]; -} diff --git a/src/lib/publish-errors.ts b/src/lib/publish-errors.ts index ef4958b..8045168 100644 --- a/src/lib/publish-errors.ts +++ b/src/lib/publish-errors.ts @@ -1,6 +1,8 @@ /* eslint-disable perfectionist/sort-modules */ /** Error classes raised by the contribution publish pipeline. Base class PublishError must precede subclasses. */ +import { type ApiErrorDetail, ApiRequestError, NOT_MEMBER_CODES } from './api-client'; + /** Base class for publish pipeline errors. Every subclass carries a user-facing `userMessage`. */ export class PublishError extends Error { readonly userMessage: string; @@ -12,43 +14,38 @@ export class PublishError extends Error { } } -/** GitHub rate limit exceeded (primary or secondary). */ +/** The ATK API (or GitHub behind it) is rate limiting the caller. */ export class PublishRateLimitError extends PublishError { - readonly retryAfterSeconds?: number; - - constructor(params: { cause?: unknown; retryAfterSeconds?: number }) { + constructor(params: { cause?: unknown }) { super( - 'GitHub rate limit exceeded while publishing contribution', - params.retryAfterSeconds - ? `GitHub is rate-limiting your account. Please try again in about ${Math.ceil(params.retryAfterSeconds / 60)} minute(s).` - : 'GitHub is rate-limiting your account. Please wait a few minutes and try again.', + 'Rate limited while publishing contribution', + 'The registry is rate-limiting requests right now. Please wait a minute and try again.', { cause: params.cause }, ); this.name = 'PublishRateLimitError'; - this.retryAfterSeconds = params.retryAfterSeconds; } } -/** Caller lacks the permissions required to fork, push, or open a PR. */ +/** Caller is signed out, not an org member, or otherwise not allowed to publish. */ export class PublishPermissionError extends PublishError { constructor(params: { cause?: unknown; detail?: string }) { super( - `Insufficient GitHub permissions: ${params.detail ?? 'unknown'}`, - 'Your GitHub account does not have the permissions needed to publish. Sign out and sign back in, making sure to approve access for the EmergentSoftware organization.', + `Insufficient permissions to publish: ${params.detail ?? 'unknown'}`, + 'Your GitHub session does not allow publishing. Sign out and sign back in, making sure you are an active member of the EmergentSoftware organization.', { cause: params.cause }, ); this.name = 'PublishPermissionError'; } } -/** The contribution branch already exists on the fork (collision with a prior attempt). */ +/** The publish branch already exists on the registry (a prior attempt, or an open PR). */ export class PublishBranchCollisionError extends PublishError { readonly branchName: string; constructor(params: { branchName: string; cause?: unknown }) { super( - `Fork branch already exists: ${params.branchName}`, - 'A branch for this contribution already exists on your fork. A pull request may already be open for this version — please bump the version number or check GitHub for an existing PR.', + `Registry branch already exists: ${params.branchName}`, + 'A branch for this contribution already exists in the registry. A pull request may already be open for this version — please bump the version number or check GitHub for an existing PR.', { cause: params.cause }, ); this.name = 'PublishBranchCollisionError'; @@ -56,14 +53,46 @@ export class PublishBranchCollisionError extends PublishError { } } -/** Transport-layer / network failure talking to GitHub. */ +/** The version is not newer than what the registry already has (`version_not_bumped` / `version_exists`). */ +export class PublishVersionConflictError extends PublishError { + readonly code: string; + + constructor(params: { cause?: unknown; code: string; detail?: string }) { + super( + `Version conflict (${params.code}): ${params.detail ?? 'unknown'}`, + params.detail ?? 'The registry already has this version. Bump the version number and try again.', + { cause: params.cause }, + ); + this.name = 'PublishVersionConflictError'; + this.code = params.code; + } +} + +/** The API rejected the payload (`validation_failed` / `schema_invalid`); `details` lists each problem. */ +export class PublishValidationError extends PublishError { + readonly code: string; + readonly details: ApiErrorDetail[]; + + constructor(params: { cause?: unknown; code: string; detail?: string; details?: ApiErrorDetail[] }) { + super( + `Publish payload rejected (${params.code}): ${params.detail ?? 'unknown'}`, + params.detail ?? 'The registry rejected this contribution. Review the issues listed and try again.', + { cause: params.cause }, + ); + this.name = 'PublishValidationError'; + this.code = params.code; + this.details = params.details ?? []; + } +} + +/** Transport-layer failure or a 5xx from the ATK API. */ export class PublishNetworkError extends PublishError { readonly status?: number; constructor(params: { cause?: unknown; status?: number }) { super( `Network failure while publishing${params.status !== undefined ? ` (HTTP ${params.status})` : ''}`, - 'We could not reach GitHub. Check your internet connection and try again.', + 'We could not reach the registry. Check your internet connection and try again.', { cause: params.cause }, ); this.name = 'PublishNetworkError'; @@ -72,48 +101,47 @@ export class PublishNetworkError extends PublishError { } /** - * Translate an arbitrary Octokit/transport error into a typed PublishError. - * Caller is responsible for wrapping the original error as `cause`. + * Translate an {@link ApiRequestError} (or any other failure) from the ATK + * API's publish endpoints into a typed {@link PublishError}. */ -export function mapOctokitError(error: unknown): PublishError { +export function mapApiError(error: unknown, context: { branchName?: string } = {}): PublishError { if (error instanceof PublishError) return error; - const status = (error as { status?: number }).status; - const message = error instanceof Error ? error.message : String(error); - - if (status === 401 || status === 403) { - // Rate limit is signalled via 403 with x-ratelimit-remaining: 0 on GitHub, - // or explicit "rate limit" text. Treat those as rate limit; otherwise permission. - const isRateLimit = - /rate limit/i.test(message) || - (error as { response?: { headers?: Record } }).response?.headers?.[ - 'x-ratelimit-remaining' - ] === '0'; - if (isRateLimit) { - const retryAfterHeader = (error as { response?: { headers?: Record } }).response - ?.headers?.['retry-after']; - const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : undefined; - return new PublishRateLimitError({ - cause: error, - ...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds ? { retryAfterSeconds } : {}), - }); - } + if (!(error instanceof ApiRequestError)) { + return new PublishNetworkError({ cause: error }); + } + + const { code, details, message, status } = error; + + if (status === 0) { + return new PublishNetworkError({ cause: error }); + } + + if (status === 401 || (status === 403 && NOT_MEMBER_CODES.has(code))) { return new PublishPermissionError({ cause: error, detail: message }); } if (status === 429) { - const retryAfterHeader = (error as { response?: { headers?: Record } }).response - ?.headers?.['retry-after']; - const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : undefined; - return new PublishRateLimitError({ - cause: error, - ...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds ? { retryAfterSeconds } : {}), - }); + return new PublishRateLimitError({ cause: error }); + } + + if (status === 409 && code === 'branch_exists') { + return new PublishBranchCollisionError({ branchName: context.branchName ?? 'unknown', cause: error }); + } + + if (status === 409 && (code === 'version_not_bumped' || code === 'version_exists')) { + return new PublishVersionConflictError({ cause: error, code, detail: message }); + } + + if (status === 400 && (code === 'validation_failed' || code === 'schema_invalid')) { + return new PublishValidationError({ cause: error, code, detail: message, details }); } - if (status === 422 && /reference already exists|already exists/i.test(message)) { - return new PublishBranchCollisionError({ branchName: 'unknown', cause: error }); + if (status >= 500) { + return new PublishNetworkError({ cause: error, status }); } - return new PublishNetworkError({ cause: error, ...(status !== undefined ? { status } : {}) }); + // 400 (invalid_reviewer, reviewers_not_allowed, ...), other 403s, and anything + // else: the API message is the best text we have. + return new PublishError(`Publish rejected (${code}): ${message}`, message, { cause: error }); } diff --git a/src/lib/publish-service.ts b/src/lib/publish-service.ts index 397bc81..d8b9139 100644 --- a/src/lib/publish-service.ts +++ b/src/lib/publish-service.ts @@ -1,26 +1,22 @@ /* eslint-disable perfectionist/sort-modules */ -import type { Octokit } from '@octokit/rest'; - +import type { PublishFile, PublishRequest } from './api/types.gen'; import type { FileEncoding } from './file-entry'; import type { Bundle } from './schemas/bundle'; import type { Manifest } from './schemas/manifest'; -import { callWithRetry, type RetryOptions } from './fetch-retry'; +import { publish as apiPublish, publishPlan as apiPublishPlan } from './api'; +import { type ApiClient, unwrap } from './api-client'; import { basename, stripCommonRoot } from './file-entry'; -import { mapOctokitError, PublishBranchCollisionError, PublishError } from './publish-errors'; -import { DEFAULT_OWNER, DEFAULT_REPO } from './registry-client'; +import { mapApiError, PublishError } from './publish-errors'; export interface PublishFileEntry { content: string; - /** `base64` content is uploaded verbatim; absent/`utf8` is encoded on upload. */ + /** `base64` content is sent verbatim; absent/`utf8` is sent as text. */ encoding?: FileEncoding; path: string; } -export type PublishProgressStep = - | 'opening-pull-request' - | 'preparing-workspace' - | 'uploading-files'; +export type PublishProgressStep = 'opening-pull-request' | 'preparing-workspace' | 'uploading-files'; export interface PublishProgressEvent { /** Non-technical copy suitable to display directly to the user. */ @@ -29,481 +25,192 @@ export interface PublishProgressEvent { } export interface PublishContributionOptions { + client: ApiClient; dryRun?: boolean; files: PublishFileEntry[]; manifest: Manifest; - octokit: Octokit; onProgress?: (event: PublishProgressEvent) => void; readme: string; - retry?: RetryOptions; + signal?: AbortSignal; +} + +export interface PublishBundleOptions { + bundle: Bundle; + client: ApiClient; + dryRun?: boolean; + onProgress?: (event: PublishProgressEvent) => void; + readme: string; signal?: AbortSignal; } export interface PublishResult { branchName: string; dryRun: boolean; + prNumber?: number; prUrl: string; + reviewers?: string[]; + /** Set when the API opened the PR but could not request reviewers. */ + reviewerWarning?: string; + /** Non-blocking advice from the API's validation (missing README, tags, unlisted files). */ + warnings: string[]; } const PROGRESS_COPY: Record = { - 'opening-pull-request': 'Opening your pull request', - 'preparing-workspace': 'Preparing your workspace', - 'uploading-files': 'Uploading your files', + 'opening-pull-request': 'Uploading your files and opening your pull request', + 'preparing-workspace': 'Preparing your contribution', + 'uploading-files': 'Validating your contribution with the registry', }; /** Synthesized PR URL marker returned when the service runs in dry-run mode. */ export const DRY_RUN_PR_URL_MARKER = 'https://dry-run.local/atk/contribute/preview'; /** - * Publish a prepared contribution by pushing a branch directly to - * EmergentSoftware/agentic-toolkit-registry via the Git Data API and opening a - * pull request against the default branch. This mirrors the CLI's `atk publish` - * flow and requires the authenticated user to have push access to the registry. + * Publish a prepared contribution through the ATK API, which validates the + * payload and opens a pull request against the registry with the signed-in + * user's own token (so the PR is authored by them). This mirrors the CLI's + * `atk publish` flow. * - * When `dryRun` is true every step runs up to — but skipping — the final PR - * creation, and the result contains the synthesized DRY_RUN_PR_URL_MARKER so - * QA can exercise the full flow without creating a real PR. + * When `dryRun` is true the payload is sent to `POST /publish/plan` instead: + * the API validates it and returns the plan without touching GitHub, and the + * result carries the synthesized DRY_RUN_PR_URL_MARKER so QA can exercise the + * full flow without creating a real PR. */ export async function publishContribution(options: PublishContributionOptions): Promise { - const { dryRun = false, files, manifest, octokit, onProgress, readme, retry, signal } = options; - const plan = buildPublishPlan({ files, manifest, readme }); - return await executePublishPlan(plan, { dryRun, octokit, onProgress, retry, signal }); -} - -export interface PublishBundleOptions { - bundle: Bundle; - dryRun?: boolean; - octokit: Octokit; - onProgress?: (event: PublishProgressEvent) => void; - readme: string; - retry?: RetryOptions; - signal?: AbortSignal; + const { client, dryRun = false, files, manifest, onProgress, readme, signal } = options; + const emit = makeEmitter(onProgress); + emit('preparing-workspace'); + const request = buildAssetPublishRequest({ files, manifest, readme }); + return await executePublish(request, { client, dryRun, emit, signal }); } /** - * Publish a bundle by pushing `bundles/{name}/{version}/bundle.json` (plus an - * optional README) to the registry and opening a pull request. A bundle is - * metadata-only — it references already-published assets — so this is a thin - * sibling of {@link publishContribution} that shares the same Git Data API - * plumbing via {@link executePublishPlan}. Branch and PR naming match the CLI's - * `atk publish` conventions (`bundle/{name}/{version}`). + * Publish a bundle (`bundle.json` plus an optional README) through the ATK + * API. A bundle is metadata-only — it references already-published assets — + * so this is a thin sibling of {@link publishContribution}. Org-scoped + * bundles carry their `org` inside `bundle.json`; the API derives the + * `bundles/@{org}/{name}/{version}/` path and branch from it. */ export async function publishBundle(options: PublishBundleOptions): Promise { - const { bundle, dryRun = false, octokit, onProgress, readme, retry, signal } = options; - const plan = buildBundlePublishPlan({ bundle, readme }); - return await executePublishPlan(plan, { dryRun, octokit, onProgress, retry, signal }); -} - -interface ExecutePublishContext { - dryRun: boolean; - octokit: Octokit; - onProgress?: (event: PublishProgressEvent) => void; - retry?: RetryOptions; - signal?: AbortSignal; + const { bundle, client, dryRun = false, onProgress, readme, signal } = options; + const emit = makeEmitter(onProgress); + emit('preparing-workspace'); + const request = buildBundlePublishRequest({ bundle, readme }); + return await executePublish(request, { client, dryRun, emit, signal }); } /** - * Push a prepared {@link PublishPlan} as a branch via the Git Data API - * (blobs → tree → commit → ref) and open a pull request against the registry's - * default branch. Shared by asset and bundle publishing. - * - * When `dryRun` is true every step runs up to — but skipping — the final PR - * creation, and the result contains the synthesized DRY_RUN_PR_URL_MARKER so - * QA can exercise the full flow without creating a real PR. + * Build the `POST /publish` payload for an asset: the manifest object plus + * every other file (entrypoint, listed files, README). A common wrapper + * folder from a browser folder upload is stripped, and any uploaded + * `manifest.json` / `README.md` is dropped in favour of the wizard's own. */ -async function executePublishPlan( - plan: PublishPlan, - context: ExecutePublishContext, -): Promise { - const { dryRun, octokit, onProgress, retry, signal } = context; - - const emit = (step: PublishProgressStep) => { - onProgress?.({ message: PROGRESS_COPY[step], step }); - }; - - try { - emit('preparing-workspace'); - - const upstreamRepo = await getRepo(octokit, retry, signal); - const defaultBranch = upstreamRepo.default_branch; - - const upstreamRef = await getRef({ - octokit, - ref: `heads/${defaultBranch}`, - retry, - signal, - }); - const baseSha = upstreamRef.object.sha; - - await ensureBranchAvailable({ - branchName: plan.branchName, - octokit, - retry, - signal, - }); - - emit('uploading-files'); - - const blobs = await Promise.all( - plan.files.map(async (file) => { - const response = await callWithRetry( - () => - octokit.rest.git.createBlob({ - content: file.encoding === 'base64' ? file.content : toBase64(file.content), - encoding: 'base64', - owner: DEFAULT_OWNER, - repo: DEFAULT_REPO, - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ); - return { path: `${plan.registryPath}${file.path}`, sha: response.data.sha }; - }), - ).catch((error: unknown) => { - throw wrapError(error); - }); - - const tree = await callWithRetry( - () => - octokit.rest.git.createTree({ - base_tree: baseSha, - owner: DEFAULT_OWNER, - repo: DEFAULT_REPO, - tree: blobs.map((blob) => ({ - mode: '100644', - path: blob.path, - sha: blob.sha, - type: 'blob', - })), - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ).catch((error: unknown) => { - throw wrapError(error); - }); - - const commit = await callWithRetry( - () => - octokit.rest.git.createCommit({ - message: plan.prTitle, - owner: DEFAULT_OWNER, - parents: [baseSha], - repo: DEFAULT_REPO, - tree: tree.data.sha, - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ).catch((error: unknown) => { - throw wrapError(error); - }); - - await callWithRetry( - () => - octokit.rest.git.createRef({ - owner: DEFAULT_OWNER, - ref: `refs/heads/${plan.branchName}`, - repo: DEFAULT_REPO, - sha: commit.data.sha, - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ).catch((error: unknown) => { - const status = (error as { status?: number }).status; - const message = error instanceof Error ? error.message : String(error); - if (status === 422 && /already exists/i.test(message)) { - throw new PublishBranchCollisionError({ branchName: plan.branchName, cause: error }); - } - throw wrapError(error); - }); - - emit('opening-pull-request'); - - if (dryRun) { - return { - branchName: plan.branchName, - dryRun: true, - prUrl: DRY_RUN_PR_URL_MARKER, - }; - } - - const pr = await callWithRetry( - () => - octokit.rest.pulls.create({ - base: defaultBranch, - body: plan.prBody, - head: plan.branchName, - owner: DEFAULT_OWNER, - repo: DEFAULT_REPO, - title: plan.prTitle, - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ).catch((error: unknown) => { - throw wrapError(error); - }); - - return { - branchName: plan.branchName, - dryRun: false, - prUrl: pr.data.html_url, - }; - } catch (error) { - if (error instanceof PublishError) throw error; - throw wrapError(error); - } -} - -interface PublishPlan { - branchName: string; - files: PublishFileEntry[]; - prBody: string; - prTitle: string; - registryPath: string; -} - -function buildPublishPlan(params: { +export function buildAssetPublishRequest(params: { files: PublishFileEntry[]; manifest: Manifest; readme: string; -}): PublishPlan { +}): PublishRequest { const { files, manifest, readme } = params; - const { name, type: assetType, version } = manifest; - const org = manifest.org; - - const branchName = org - ? `asset/${assetType}/${org}/${name}/${version}` - : `asset/${assetType}/${name}/${version}`; - - const registryPath = org - ? `assets/${assetType}s/@${org}/${name}/${version}/` - : `assets/${assetType}s/${name}/${version}/`; const normalized = stripCommonRoot(files); - const filtered = normalized.filter((file) => { const base = basename(file.path).toLowerCase(); - if (base === 'manifest.json') return false; - if (base === 'readme.md') return false; - return true; + return base !== 'manifest.json' && base !== 'readme.md'; }); - const payloadFiles: PublishFileEntry[] = [ - { content: `${JSON.stringify(manifest, null, 2)}\n`, path: 'manifest.json' }, - ...filtered, - ]; + // The registry schema only accepts a concrete `files` array; the "auto" + // sentinel is CLI-side sugar and must never reach the API. + const concreteManifest: Manifest = + manifest.files === 'auto' + ? { ...manifest, files: filtered.map((file) => file.path).filter((path) => path !== manifest.entrypoint) } + : manifest; + + const payloadFiles: PublishFile[] = filtered.map(toPublishFile); if (readme.trim().length > 0) { - payloadFiles.push({ content: readme.endsWith('\n') ? readme : `${readme}\n`, path: 'README.md' }); + payloadFiles.push({ content: readme.endsWith('\n') ? readme : `${readme}\n`, encoding: 'utf8', path: 'README.md' }); } - const listedFiles = payloadFiles.map((file) => file.path); - const prTitle = `feat(registry): add ${assetType} ${name}@${version}`; - const prBody = generatePrBody({ listedFiles, manifest }); - - return { branchName, files: payloadFiles, prBody, prTitle, registryPath }; + return { + client: 'web', + files: payloadFiles, + kind: 'asset', + manifest: concreteManifest as Record, + }; } -function buildBundlePublishPlan(params: { bundle: Bundle; readme: string }): PublishPlan { +/** Build the `POST /publish` payload for a bundle: `bundle.json` plus an optional README. */ +export function buildBundlePublishRequest(params: { bundle: Bundle; readme: string }): PublishRequest { const { bundle, readme } = params; - const { name, version } = bundle; - - // Bundles are always global (BundleSchema has no org) and versioned, matching - // the CLI publisher's `bundle/{name}/{version}` branch + path conventions. - const branchName = `bundle/${name}/${version}`; - const registryPath = `bundles/${name}/${version}/`; - - const payloadFiles: PublishFileEntry[] = [ - { content: `${JSON.stringify(bundle, null, 2)}\n`, path: 'bundle.json' }, - ]; + const payloadFiles: PublishFile[] = []; if (readme.trim().length > 0) { - payloadFiles.push({ content: readme.endsWith('\n') ? readme : `${readme}\n`, path: 'README.md' }); + payloadFiles.push({ content: readme.endsWith('\n') ? readme : `${readme}\n`, encoding: 'utf8', path: 'README.md' }); } - - const listedFiles = payloadFiles.map((file) => file.path); - const prTitle = `feat(registry): add bundle ${name}@${version}`; - const prBody = generateBundlePrBody({ bundle, listedFiles }); - - return { branchName, files: payloadFiles, prBody, prTitle, registryPath }; + return { + client: 'web', + files: payloadFiles, + kind: 'bundle', + manifest: bundle as Record, + }; } -function generateBundlePrBody(params: { bundle: Bundle; listedFiles: string[] }): string { - const { bundle, listedFiles } = params; - const lines: string[] = [`## New Bundle: ${bundle.name}`, '']; - lines.push(`**Version:** ${bundle.version}`); - lines.push(`**Author:** ${bundle.author}`); - lines.push(''); - lines.push('### Description'); - lines.push(''); - lines.push(bundle.description); - lines.push(''); - lines.push('### Assets'); - lines.push(''); - for (const asset of bundle.assets) { - const scope = asset.org ? `@${asset.org}/` : ''; - const pin = asset.version ? `@${asset.version}` : ' (latest)'; - lines.push(`- ${asset.type}:${scope}${asset.name}${pin}`); - } - lines.push(''); - lines.push('### Tags'); - lines.push(''); - lines.push(bundle.tags && bundle.tags.length > 0 ? bundle.tags.join(', ') : 'none'); - lines.push(''); - lines.push('### Files'); - lines.push(''); - for (const file of listedFiles) lines.push(`- ${file}`); - lines.push(''); - lines.push('### Checklist'); - lines.push(''); - lines.push('- [ ] Bundle schema is valid'); - lines.push('- [ ] All referenced assets exist in the registry'); - lines.push('- [ ] Bundle installs cleanly via `atk install`'); - lines.push(''); - lines.push('---'); - lines.push('*Published via the ATK contribute web flow*'); - return lines.join('\n'); +/** + * The branch the API will push for this request, computed locally only so a + * `409 branch_exists` can name it in the error. Mirrors the CLI convention: + * `asset/{type}/[{org}/]{name}/{version}` and `bundle/[{org}/]{name}/{version}`. + */ +export function expectedBranchName(request: PublishRequest): string { + const manifest = request.manifest as { name?: string; org?: string; type?: string; version?: string }; + const scope = manifest.org ? `${manifest.org}/` : ''; + if (request.kind === 'bundle') return `bundle/${scope}${manifest.name}/${manifest.version}`; + return `asset/${manifest.type}/${scope}${manifest.name}/${manifest.version}`; } -function generatePrBody(params: { listedFiles: string[]; manifest: Manifest }): string { - const { listedFiles, manifest } = params; - const lines: string[] = [`## New Asset: ${manifest.name}`, '']; - lines.push(`**Type:** ${manifest.type}`); - lines.push(`**Version:** ${manifest.version}`); - lines.push(`**Author:** ${manifest.author}`); - lines.push(''); - lines.push('### Description'); - lines.push(''); - lines.push(manifest.description); - lines.push(''); - lines.push('### Tool Compatibility'); - lines.push(''); - if (manifest.tools && manifest.tools.length > 0) { - for (const tool of manifest.tools) lines.push(`- ${tool}`); - } else { - lines.push('none'); - } - lines.push(''); - lines.push('### Tags'); - lines.push(''); - lines.push(manifest.tags && manifest.tags.length > 0 ? manifest.tags.join(', ') : 'none'); - lines.push(''); - lines.push('### Dependencies'); - lines.push(''); - if (manifest.dependencies && manifest.dependencies.length > 0) { - for (const dep of manifest.dependencies) { - lines.push(`- ${dep.type}:${dep.name}${dep.version ? `@${dep.version}` : ''}`); - } - } else { - lines.push('none'); - } - lines.push(''); - lines.push('### Files'); - lines.push(''); - for (const file of listedFiles) lines.push(`- ${file}`); - lines.push(''); - lines.push('### Checklist'); - lines.push(''); - lines.push('- [ ] Manifest schema is valid'); - lines.push('- [ ] All referenced files are present'); - lines.push('- [ ] Asset has been tested locally'); - lines.push('- [ ] README.md is included'); - lines.push(''); - lines.push('---'); - lines.push('*Published via the ATK contribute web flow*'); - return lines.join('\n'); +interface ExecutePublishContext { + client: ApiClient; + dryRun: boolean; + emit: (step: PublishProgressStep) => void; + signal?: AbortSignal; } -async function getRepo( - octokit: Octokit, - retry: RetryOptions | undefined, - signal: AbortSignal | undefined, -): Promise<{ default_branch: string }> { - try { - const response = await callWithRetry( - () => - octokit.rest.repos.get({ - owner: DEFAULT_OWNER, - repo: DEFAULT_REPO, - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ); - return { default_branch: response.data.default_branch }; - } catch (error) { - throw wrapError(error); - } -} +async function executePublish(request: PublishRequest, context: ExecutePublishContext): Promise { + const { client, dryRun, emit, signal } = context; + const branchName = expectedBranchName(request); -async function getRef(params: { - octokit: Octokit; - ref: string; - retry: RetryOptions | undefined; - signal: AbortSignal | undefined; -}): Promise<{ object: { sha: string } }> { - const { octokit, ref, retry, signal } = params; try { - const response = await callWithRetry( - () => - octokit.rest.git.getRef({ - owner: DEFAULT_OWNER, - ref, - repo: DEFAULT_REPO, - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ); - return { object: { sha: response.data.object.sha } }; - } catch (error) { - throw wrapError(error); - } -} + if (dryRun) { + emit('uploading-files'); + const plan = unwrap(await apiPublishPlan({ body: request, client, signal }), 'the publish plan'); + return { + branchName: plan.branchName || branchName, + dryRun: true, + prUrl: DRY_RUN_PR_URL_MARKER, + reviewers: plan.reviewers, + warnings: plan.warnings ?? [], + }; + } -async function ensureBranchAvailable(params: { - branchName: string; - octokit: Octokit; - retry: RetryOptions | undefined; - signal: AbortSignal | undefined; -}): Promise { - const { branchName, octokit, retry, signal } = params; - try { - await callWithRetry( - () => - octokit.rest.git.getRef({ - owner: DEFAULT_OWNER, - ref: `heads/${branchName}`, - repo: DEFAULT_REPO, - ...(signal ? { request: { signal } } : {}), - }), - retry, - signal, - ); - // If we reached this point the ref exists — collision. - throw new PublishBranchCollisionError({ branchName }); + emit('opening-pull-request'); + const published = unwrap(await apiPublish({ body: request, client, signal }), 'the publish request'); + return { + branchName: published.branchName || branchName, + dryRun: false, + prNumber: published.prNumber, + prUrl: published.prUrl, + ...(published.reviewerWarning ? { reviewerWarning: published.reviewerWarning } : {}), + reviewers: published.reviewers, + warnings: published.warnings ?? [], + }; } catch (error) { - if (error instanceof PublishBranchCollisionError) throw error; - const status = (error as { status?: number }).status; - if (status === 404) return; - throw wrapError(error); + if (error instanceof PublishError) throw error; + throw mapApiError(error, { branchName }); } } -function wrapError(error: unknown): PublishError { - return mapOctokitError(error); +function makeEmitter(onProgress?: (event: PublishProgressEvent) => void) { + return (step: PublishProgressStep) => { + onProgress?.({ message: PROGRESS_COPY[step], step }); + }; } -function toBase64(content: string): string { - if (typeof btoa === 'function') { - // Encode UTF-8 safely: convert to bytes first to handle non-ASCII characters. - const bytes = new TextEncoder().encode(content); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); - } - // Node fallback used in tests. - return Buffer.from(content, 'utf-8').toString('base64'); +function toPublishFile(file: PublishFileEntry): PublishFile { + return { content: file.content, encoding: file.encoding === 'base64' ? 'base64' : 'utf8', path: file.path }; } diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 119826c..ad085d0 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -3,6 +3,8 @@ import type { AssetType } from './schemas'; /** Query-key factory for registry- and session-backed queries. */ export const queryKeys = { all: () => ['registry'] as const, + assetFiles: (ref: { name: string; org?: string; type: AssetType; version: string }) => + ['registry', 'asset-files', ref.type, ref.org ?? '', ref.name, ref.version] as const, assetManifest: (ref: { name: string; org?: string; type: AssetType; version: string }) => ['registry', 'asset-manifest', ref.type, ref.org ?? '', ref.name, ref.version] as const, assetReadme: (ref: { name: string; org?: string; type: AssetType; version: string }) => diff --git a/src/lib/registry-client.ts b/src/lib/registry-client.ts index bbd5445..05f3fb2 100644 --- a/src/lib/registry-client.ts +++ b/src/lib/registry-client.ts @@ -1,18 +1,29 @@ -import type { Octokit } from '@octokit/rest'; import type { ZodType } from 'zod'; -import { callWithRetry, type RetryOptions } from './fetch-retry'; +import { z } from 'zod'; + +import { getAssetManifest, getAssetReadme, getBundleManifest, getRegistry, listAssetFiles } from './api'; +import { type ApiClient, ApiRequestError, type ApiResult, unwrap } from './api-client'; import { RegistryFetchError, RegistryNotFoundError, RegistryParseError } from './registry-errors'; -import { - assetPathSegments, - bundlePathSegments, - toRegistryPath, -} from './registry-paths'; -import { type AssetType, type Bundle, BundleSchema, type Manifest, ManifestSchema } from './schemas'; +import { AssetType, type Bundle, BundleSchema, type Manifest, ManifestSchema } from './schemas'; import { type Registry, RegistrySchema } from './schemas/registry'; -export const DEFAULT_OWNER = 'EmergentSoftware'; -export const DEFAULT_REPO = 'agentic-toolkit-registry'; +/** One file in an asset version directory, as listed by the API. */ +export interface AssetFileEntry { + /** Path relative to the version directory, e.g. `SKILL.md` or `reference/guide.md`. */ + path: string; + sha: string; + size: number; +} + +/** The API's directory listing for an asset version (`manifest.json` and `README.md` included). */ +export interface AssetFileList { + files: AssetFileEntry[]; + name: string; + org?: string; + type: AssetType; + version: string; +} /** A pointer to a specific asset version in the registry. */ export interface AssetManifestRef { @@ -25,70 +36,89 @@ export interface AssetManifestRef { /** A pointer to a specific bundle version in the registry. */ export interface BundleManifestRef { name: string; - /** - * Bare org name (no `@`) for org-scoped bundles; omit for global bundles. - * An org-scoped bundle resolves under `bundles/@{org}/{name}/{version}/`. - */ + /** Bare org name (no `@`) for org-scoped bundles; omit for global bundles. */ org?: string; - /** Bundle version — resolves to `bundles/[@{org}/]{name}/{version}/bundle.json`. Take it from the registry index entry's `version`. */ + /** Bundle version (or `latest`). Take it from the registry index entry's `version`. */ version: string; } /** - * Common options accepted by every registry client call. An authenticated - * Octokit instance is required — the CLI can no longer talk to the registry - * without a signed-in, org-verified session. + * Common options accepted by every registry client call. A configured + * {@link ApiClient} is required: the web app cannot read the registry without + * a signed-in, org-verified session. */ export interface RegistryClientOptions { - octokit: Octokit; - owner?: string; - ref?: string; - repo?: string; - retry?: RetryOptions; + client: ApiClient; signal?: AbortSignal; } +const AssetFileListSchema = z.object({ + files: z.array(z.object({ path: z.string(), sha: z.string(), size: z.number() })), + name: z.string(), + org: z.string().nullable().optional(), + type: AssetType, + version: z.string(), +}); + +/** Fetch the API's file listing for an asset version. */ +export async function fetchAssetFiles(ref: AssetManifestRef, options: RegistryClientOptions): Promise { + const label = `${describeAsset(ref)} file list`; + const result = await listAssetFiles({ + client: options.client, + path: assetPath(ref), + query: orgQuery(ref.org), + signal: options.signal, + }); + const parsed = parseResponse(unwrapRegistry(result, label), AssetFileListSchema, label); + return { ...parsed, org: parsed.org ?? undefined }; +} + /** Fetch and validate a specific asset's `manifest.json`. */ -export async function fetchAssetManifest( - ref: AssetManifestRef, - options: RegistryClientOptions, -): Promise { - const path = buildAssetManifestPath(ref); - return await fetchAndParse(path, ManifestSchema, options); +export async function fetchAssetManifest(ref: AssetManifestRef, options: RegistryClientOptions): Promise { + const label = `${describeAsset(ref)} manifest`; + const result = await getAssetManifest({ + client: options.client, + path: assetPath(ref), + query: orgQuery(ref.org), + signal: options.signal, + }); + return parseResponse(unwrapRegistry(result, label), ManifestSchema, label); } /** * Fetch an asset's `README.md` as raw markdown. Returns null when the README * is absent (HTTP 404) so callers can degrade gracefully. */ -export async function fetchAssetReadme( - ref: AssetManifestRef, - options: RegistryClientOptions, -): Promise { - const manifestPath = buildAssetManifestPath(ref); - const readmePath = manifestPath.replace(/manifest\.json$/, 'README.md'); - try { - return await fetchContent(readmePath, options); - } catch (error) { - if (error instanceof RegistryNotFoundError) return null; - throw error; - } +export async function fetchAssetReadme(ref: AssetManifestRef, options: RegistryClientOptions): Promise { + const result = await getAssetReadme({ + client: options.client, + parseAs: 'text', + path: assetPath(ref), + query: orgQuery(ref.org), + signal: options.signal, + }); + if (result.response?.status === 404) return null; + const data = unwrapRegistry(result, `${describeAsset(ref)} README`); + return typeof data === 'string' ? data : String(data); } /** Fetch and validate a bundle's `bundle.json` from its versioned registry path. */ -export async function fetchBundleManifest( - ref: BundleManifestRef, - options: RegistryClientOptions, -): Promise { - const path = toRegistryPath( - bundlePathSegments({ name: ref.name, org: ref.org, version: ref.version }), - ); - return await fetchAndParse(path, BundleSchema, options); +export async function fetchBundleManifest(ref: BundleManifestRef, options: RegistryClientOptions): Promise { + const label = `bundle ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version} manifest`; + const result = await getBundleManifest({ + client: options.client, + path: { name: ref.name, version: ref.version }, + query: orgQuery(ref.org), + signal: options.signal, + }); + return parseResponse(unwrapRegistry(result, label), BundleSchema, label); } -/** Fetch and validate the top-level `registry.json` from the GitHub registry repo. */ +/** Fetch and validate the registry index (`registry.json`) from the ATK API. */ export async function fetchRegistry(options: RegistryClientOptions): Promise { - return await fetchAndParse('registry.json', RegistrySchema, options); + const label = 'the registry index'; + const result = await getRegistry({ client: options.client, signal: options.signal }); + return parseResponse(unwrapRegistry(result, label), RegistrySchema, label); } /** @@ -102,9 +132,7 @@ export function findExistingAsset( query: { name: string; org?: string; type: AssetType }, ): undefined | { latest: string; org?: string } { const { name, org, type } = query; - const match = registry.assets.find( - (a) => a.name === name && a.type === type && a.org === (org || undefined), - ); + const match = registry.assets.find((a) => a.name === name && a.type === type && a.org === (org || undefined)); if (!match) return undefined; return { latest: match.latest, org: match.org }; } @@ -126,90 +154,59 @@ export function findExistingBundle( return { latest: match.version, org: match.org }; } -function buildAssetManifestPath(ref: AssetManifestRef): string { - return toRegistryPath(assetPathSegments(ref, 'manifest.json')); +function assetPath(ref: AssetManifestRef): { name: string; type: AssetType; version: string } { + return { name: ref.name, type: ref.type, version: ref.version }; } -function buildResourceLabel(path: string, options: RegistryClientOptions): string { - const owner = options.owner ?? DEFAULT_OWNER; - const repo = options.repo ?? DEFAULT_REPO; - const refSuffix = options.ref ? `@${options.ref}` : ''; - return `${owner}/${repo}${refSuffix}:${path}`; +function describeAsset(ref: AssetManifestRef): string { + return `${ref.type} ${ref.org ? `@${ref.org}/` : ''}${ref.name}@${ref.version}`; } -async function fetchAndParse( - path: string, - schema: ZodType, - options: RegistryClientOptions, -): Promise { - const decoded = await fetchContent(path, options); - const label = buildResourceLabel(path, options); - - let parsed: unknown; - try { - parsed = JSON.parse(decoded); - } catch (cause) { - throw new RegistryParseError(`Registry content is not valid JSON: ${label}`, { - cause, - payload: decoded, - url: label, - }); - } +function orgQuery(org: string | undefined): undefined | { org?: string } { + return org ? { org } : undefined; +} - const result = schema.safeParse(parsed); +/** Validate an already-parsed API response body against a Zod schema. */ +function parseResponse(data: unknown, schema: ZodType, label: string): T { + const result = schema.safeParse(data); if (!result.success) { throw new RegistryParseError(`Registry content failed schema validation: ${label}`, { - payload: decoded, + payload: safeStringify(data), url: label, zodError: result.error, }); } - return result.data; } +function safeStringify(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + /** - * Fetch the raw UTF-8 contents of a file from the registry repo via Octokit. - * - * Uses `mediaType: { format: 'raw' }` so GitHub returns the decoded file body - * directly rather than a base64-encoded envelope. + * {@link unwrap} an API result, translating failures into the registry error + * types the UI switches on: 404 → {@link RegistryNotFoundError}, everything + * else → {@link RegistryFetchError} (with the HTTP status when there was one). */ -async function fetchContent(path: string, options: RegistryClientOptions): Promise { - const owner = options.owner ?? DEFAULT_OWNER; - const repo = options.repo ?? DEFAULT_REPO; - const label = buildResourceLabel(path, options); - - let raw: unknown; +function unwrapRegistry(result: ApiResult, resource: string): T { try { - const response = await callWithRetry( - () => - options.octokit.rest.repos.getContent({ - mediaType: { format: 'raw' }, - owner, - path, - repo, - ...(options.ref ? { ref: options.ref } : {}), - request: options.signal ? { signal: options.signal } : undefined, - }), - options.retry, - options.signal, - ); - raw = response.data; - } catch (cause: unknown) { - if (options.signal?.aborted) throw cause; - const status = (cause as { status?: number }).status; - if (status === 404) { - throw new RegistryNotFoundError(`Registry resource not found: ${label}`, { url: label }); + return unwrap(result, resource); + } catch (error) { + if (error instanceof ApiRequestError) { + if (error.status === 404) { + throw new RegistryNotFoundError(`Registry resource not found: ${resource}`, { url: resource }); + } + throw new RegistryFetchError(error.message, { + cause: error, + status: error.status || undefined, + url: resource, + }); } - throw new RegistryFetchError( - `Registry request failed${status !== undefined ? ` with HTTP ${status}` : ''}: ${label}`, - { cause, status, url: label }, - ); + throw error; } - - if (typeof raw === 'string') return raw; - throw new RegistryParseError(`Registry content was not a raw string: ${label}`, { - payload: String(raw), - url: label, - }); } diff --git a/src/lib/registry-paths.ts b/src/lib/registry-paths.ts deleted file mode 100644 index 07146c8..0000000 --- a/src/lib/registry-paths.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { AssetType } from './schemas/manifest'; - -/** - * Canonical registry path construction for assets and bundles. - * - * Implements the org-scoping convention (OrgScopedBundlesDesign §2): a stored - * `org` value is always **bare** (`cupay`, never `@cupay`). The `@` is a - * filesystem-layout prefix only — this module is the single place that prepends - * it, so `registry-client` (Octokit paths) and `download-service` (GitHub API - * URLs) can never drift on the convention again (audit W1). - * - * Builders return raw, unencoded path **segments**. Consumers pick their own - * encoding: Octokit is handed the joined raw path (it encodes internally), - * while manually-constructed GitHub API URLs run each segment through - * {@link encodePathSegment}. - */ - -/** A pointer to an asset version directory (or a file within it). */ -export interface AssetPathRef { - name: string; - /** Bare org name (no `@`); omit for a global asset. */ - org?: string; - type: AssetType; - version: string; -} - -/** A pointer to a bundle version directory (or a file within it). */ -export interface BundlePathRef { - name: string; - /** Bare org name (no `@`); omit for a global bundle. */ - org?: string; - version: string; -} - -/** - * Raw path segments for an asset: - * `assets/{type}s/[@{org}/]{name}/{version}[/{file}]`. - */ -export function assetPathSegments(ref: AssetPathRef, file?: string): string[] { - const segments = ['assets', `${ref.type}s`]; - if (ref.org) segments.push(`@${ref.org}`); - segments.push(ref.name, ref.version); - if (file) for (const part of file.split('/')) segments.push(part); - return segments; -} - -/** - * Raw path segments for a bundle: - * `bundles/[@{org}/]{name}/{version}[/{file}]`. Defaults `file` to - * `bundle.json`; pass an empty string for the version directory itself. - */ -export function bundlePathSegments(ref: BundlePathRef, file = 'bundle.json'): string[] { - const segments = ['bundles']; - if (ref.org) segments.push(`@${ref.org}`); - segments.push(ref.name, ref.version); - if (file) for (const part of file.split('/')) segments.push(part); - return segments; -} - -/** - * URL-encode a single path segment while preserving a leading `@` (which is a - * legal, and by convention un-escaped, path character in the registry layout). - */ -export function encodePathSegment(segment: string): string { - return encodeURIComponent(segment).replace(/%40/g, '@'); -} - -/** Percent-encode raw segments into a slash-joined path (for GitHub API URLs). */ -export function encodeRegistryPath(segments: string[]): string { - return segments.map(encodePathSegment).join('/'); -} - -/** Join raw segments into a registry path (for Octokit, which encodes internally). */ -export function toRegistryPath(segments: string[]): string { - return segments.join('/'); -} diff --git a/src/lib/session.ts b/src/lib/session.ts index 2bcef66..d57afc4 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,5 +1,8 @@ /** Constants, types, and helpers for the GitHub OAuth session layer. */ +import { authGitHubExchange } from './api'; +import { ApiRequestError, createApiClient, unwrap } from './api-client'; + export const SESSION_STORAGE_KEYS = { oauthState: 'atk:session:oauth-state', pendingReturn: 'atk:session:pending-return', @@ -16,12 +19,7 @@ export interface OAuthStateRecord { } /** Session status machine. */ -export type SessionStatus = - | 'authenticating' - | 'member' - | 'non-member' - | 'signed-out' - | 'verifying'; +export type SessionStatus = 'authenticating' | 'member' | 'non-member' | 'signed-out' | 'verifying'; /** Build the GitHub authorize URL for an OAuth redirect. */ export function buildAuthorizeUrl(params: { @@ -89,29 +87,25 @@ export function defaultRedirectUri(): string { return `${window.location.origin}${base}/#/auth/callback`; } -/** Exchange an OAuth code for an access token via the auth-function broker. */ +/** + * Exchange an OAuth code for an access token via the ATK API + * (`POST /auth/github/exchange`). The API holds the OAuth App's client secret + * and returns GitHub's token response verbatim. + */ export async function exchangeCodeForToken(code: string, signal?: AbortSignal): Promise { - const response = await fetch(`${getAuthFunctionUrl()}/api/auth/exchange`, { - body: JSON.stringify({ code }), - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - signal, - }); - - if (!response.ok) { - let detail: string | undefined; - try { - const body = (await response.json()) as { error?: string; message?: string }; - detail = body.message ?? body.error; - } catch { - // fallthrough + const result = await authGitHubExchange({ body: { code }, client: createApiClient(null), signal }); + + let payload: { access_token?: string }; + try { + payload = unwrap(result, 'the sign-in token'); + } catch (error) { + if (error instanceof ApiRequestError) { + const status = error.status === 0 ? 'network error' : `HTTP ${error.status}`; + throw new Error(`Auth exchange failed (${status}): ${error.message}`, { cause: error }); } - throw new Error( - `Auth exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ''}`, - ); + throw error; } - const payload = (await response.json()) as { access_token?: string }; if (!payload.access_token) { throw new Error('Auth exchange succeeded but response contained no access_token'); } @@ -132,17 +126,6 @@ export function generateOAuthState(): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } -/** Read the auth-function broker URL from the Vite environment. */ -export function getAuthFunctionUrl(): string { - const value = import.meta.env.VITE_AUTH_FUNCTION_URL; - if (!value) { - throw new Error( - 'VITE_AUTH_FUNCTION_URL is not set. Copy .env.example to .env.local and fill in your auth-function URL.', - ); - } - return value.replace(/\/$/, ''); -} - /** Read the OAuth client ID from the Vite environment. */ export function getClientId(): string { const value = import.meta.env.VITE_GITHUB_OAUTH_CLIENT_ID; diff --git a/src/providers/SessionProvider.tsx b/src/providers/SessionProvider.tsx index 6d5e451..8108698 100644 --- a/src/providers/SessionProvider.tsx +++ b/src/providers/SessionProvider.tsx @@ -1,20 +1,13 @@ -import { Octokit } from '@octokit/rest'; import { useQuery } from '@tanstack/react-query'; -import { - createContext, - type ReactNode, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; +import { createContext, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { me } from '@/lib/api'; +import { type ApiClient, createApiClient, isApiError, unwrap } from '@/lib/api-client'; import { queryKeys } from '@/lib/query-keys'; import { buildAuthorizeUrl, clearToken, defaultRedirectUri, - EMERGENT_ORG, fingerprintToken, generateOAuthState, getClientId, @@ -26,9 +19,10 @@ import { } from '@/lib/session'; export interface SessionContextValue { + /** ATK API client configured with the session token; null when signed out. */ + api: ApiClient | null; /** Called by the AuthCallback route after a successful code exchange. */ completeSignIn: (token: string) => void; - octokit: null | Octokit; signIn: (returnPath?: string) => void; signOut: () => void; status: SessionStatus; @@ -52,68 +46,66 @@ export function SessionProvider({ children }: SessionProviderProps) { const [token, setToken] = useState(() => readToken()); const [isAuthenticating, setIsAuthenticating] = useState(false); - const octokit = useMemo(() => (token ? new Octokit({ auth: token }) : null), [token]); - - const verifyQuery = useQuery<{ membership: boolean; user: SessionUser }, Error>({ - enabled: Boolean(token && octokit), - queryFn: async () => { - if (!octokit) throw new Error('Octokit client not initialized'); - const userResp = await octokit.rest.users.getAuthenticated(); - const sessionUser: SessionUser = { - avatarUrl: userResp.data.avatar_url ?? null, - login: userResp.data.login, - name: userResp.data.name ?? null, + const api = useMemo(() => (token ? createApiClient(token) : null), [token]); + + // `GET /me` both identifies the caller and enforces EmergentSoftware + // membership: a 2xx means "active member"; 401 means the token is dead; + // 403 `not_org_member` / `org_membership_unverifiable` means "signed in, but + // not allowed in". + const verifyQuery = useQuery({ + enabled: Boolean(token && api), + queryFn: async ({ signal }) => { + if (!api) throw new Error('API client not initialized'); + const principal = unwrap(await me({ client: api, signal }), 'your GitHub account'); + return { + avatarUrl: principal.avatarUrl ?? null, + login: principal.login, + name: principal.name ?? null, }; - let membership = false; - try { - // /user/memberships/orgs/{org} — checks the AUTHENTICATED user's own - // membership. Works regardless of membership visibility (public/private) - // and does not depend on the "requester can see the org members" rule - // that trips up `checkMembershipForUser`. - const res = await octokit.rest.orgs.getMembershipForAuthenticatedUser({ - org: EMERGENT_ORG, - }); - membership = res.data.state === 'active'; - } catch (error: unknown) { - const err = error as { message?: string; response?: { data?: unknown; headers?: Record }; status?: number }; - - console.warn('[SessionProvider] Org-membership check failed:', { - body: err.response?.data, - hint: - err.status === 404 - ? 'Likely OAuth App restriction: the EmergentSoftware org must approve this OAuth App. Visit https://github.com/orgs/EmergentSoftware/policies/applications' - : err.status === 403 - ? 'Likely SAML SSO: authorize the OAuth token for the org at https://github.com/settings/tokens' - : undefined, - message: err.message, - ssoHeader: err.response?.headers?.['x-github-sso'], - status: err.status, - }); - // 404 → not a member OR OAuth App is not approved for the org. - // 403 → forbidden (token lacks read:org, or SAML SSO not authorized). - if (err.status === 404 || err.status === 403) membership = false; - else throw error; - } - return { membership, user: sessionUser }; }, queryKey: token ? queryKeys.session.user(fingerprintToken(token)) : ['session', 'user', 'none'], retry: false, staleTime: 5 * 60 * 1000, }); + const verifyError = verifyQuery.error; + const tokenRejected = isApiError(verifyError, undefined, 401); + + useEffect(() => { + if (!verifyError) return; + if (isApiError(verifyError, 'org_membership_unverifiable')) { + console.warn('[SessionProvider] Org membership could not be verified:', { + hints: [ + 'OAuth App restriction: the EmergentSoftware org must approve this OAuth App at https://github.com/orgs/EmergentSoftware/policies/applications', + 'SAML SSO: authorize the OAuth token for the org at https://github.com/settings/tokens', + ], + message: verifyError.message, + status: verifyError.status, + }); + } else if (!isApiError(verifyError, 'not_org_member') && !tokenRejected) { + console.warn('[SessionProvider] Session verification failed:', verifyError); + } + }, [tokenRejected, verifyError]); + const status = useMemo(() => { if (isAuthenticating) return 'authenticating'; if (!token) return 'signed-out'; if (verifyQuery.isPending || verifyQuery.isFetching) return 'verifying'; - if (verifyQuery.isError) return 'non-member'; - return verifyQuery.data?.membership ? 'member' : 'non-member'; - }, [isAuthenticating, token, verifyQuery.data, verifyQuery.isError, verifyQuery.isFetching, verifyQuery.isPending]); + if (verifyQuery.isError) return tokenRejected ? 'signed-out' : 'non-member'; + return verifyQuery.data ? 'member' : 'non-member'; + }, [ + isAuthenticating, + token, + tokenRejected, + verifyQuery.data, + verifyQuery.isError, + verifyQuery.isFetching, + verifyQuery.isPending, + ]); const signIn = useCallback((returnPath?: string) => { // HashRouter URL after the leading '#'. Fallback to '/'. - const currentHash = window.location.hash.startsWith('#') - ? window.location.hash.slice(1) - : ''; + const currentHash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''; const resolvedReturn = returnPath ?? (currentHash || '/'); const stateValue = generateOAuthState(); writeOAuthState({ returnPath: resolvedReturn, state: stateValue }); @@ -139,6 +131,12 @@ export function SessionProvider({ children }: SessionProviderProps) { setIsAuthenticating(false); }, []); + // A 401 from the API means the stored token is dead: drop it so the app + // returns to the signed-out landing instead of retrying forever. + useEffect(() => { + if (tokenRejected) signOut(); + }, [signOut, tokenRejected]); + // Keep isAuthenticating in sync if the user returns to the tab with an existing token. useEffect(() => { if (token) setIsAuthenticating(false); @@ -146,15 +144,15 @@ export function SessionProvider({ children }: SessionProviderProps) { const value = useMemo( () => ({ + api, completeSignIn, - octokit, signIn, signOut, status, token, - user: verifyQuery.data?.user ?? null, + user: verifyQuery.data ?? null, }), - [completeSignIn, octokit, signIn, signOut, status, token, verifyQuery.data?.user], + [api, completeSignIn, signIn, signOut, status, token, verifyQuery.data], ); return {children}; diff --git a/src/routes/AssetDetail.tsx b/src/routes/AssetDetail.tsx index e8a7fb3..33bce6b 100644 --- a/src/routes/AssetDetail.tsx +++ b/src/routes/AssetDetail.tsx @@ -14,12 +14,12 @@ import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { PageHeader } from '@/components/PageHeader'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAssetFiles } from '@/hooks/useAssetFiles'; import { useAssetManifest } from '@/hooks/useAssetManifest'; import { useAssetReadme } from '@/hooks/useAssetReadme'; import { useDownloadAsset } from '@/hooks/useDownloadAsset'; import { useManifestGraph } from '@/hooks/useManifestGraph'; import { useRegistry } from '@/hooks/useRegistry'; -import { listAssetFiles } from '@/lib/file-list'; import { type AssetManifestRef } from '@/lib/registry-client'; import { RegistryNotFoundError } from '@/lib/registry-errors'; import { cn } from '@/lib/utils'; @@ -44,13 +44,11 @@ export function AssetDetailRoute() { const manifestQuery = useAssetManifest(ref); const readmeQuery = useAssetReadme(ref); + const filesQuery = useAssetFiles(ref); const registryQuery = useRegistry(); const { download, isDownloading } = useDownloadAsset(); - const depRefs = useMemo( - () => collectDirectDepRefs(manifestQuery.data), - [manifestQuery.data], - ); + const depRefs = useMemo(() => collectDirectDepRefs(manifestQuery.data), [manifestQuery.data]); const depGraph = useManifestGraph(depRefs); const registryAsset = findRegistryAsset(registryQuery.data?.assets, assetType, name, org); @@ -163,7 +161,11 @@ export function AssetDetailRoute() { {manifest.author} {manifest.description} - {manifest.org ? {manifest.org} : global} + {manifest.org ? ( + {manifest.org} + ) : ( + global + )} {manifest.tags && manifest.tags.length > 0 ? ( @@ -216,19 +218,21 @@ export function AssetDetailRoute() { file.path) ?? [], + depGraph.manifests, + depGraph.files, + depGraph.order, + )} + isLoading={filesQuery.isLoading || depGraph.isLoading} testId='asset-detail-files' />

README

- +
); @@ -260,12 +264,14 @@ function BackToBrowseLink() { function buildAssetFileGroups( manifest: Manifest, + primaryFiles: string[], depManifests: Map, + depFiles: Map, order: string[], ): FileGroup[] { const groups: FileGroup[] = [ { - files: listAssetFiles(manifest), + files: primaryFiles, name: manifest.name, testId: `files-group-${manifest.name}`, }, @@ -274,7 +280,7 @@ function buildAssetFileGroups( const dep = depManifests.get(key); if (!dep) continue; groups.push({ - files: listAssetFiles(dep), + files: depFiles.get(key) ?? [], name: dep.name, testId: `files-group-${dep.name}-${dep.version}`, version: dep.version, @@ -312,15 +318,7 @@ function MetadataRow({ children, label }: { children: React.ReactNode; label: st ); } -function ReadmeView({ - isError, - isLoading, - readme, -}: { - isError: boolean; - isLoading: boolean; - readme: null | string; -}) { +function ReadmeView({ isError, isLoading, readme }: { isError: boolean; isLoading: boolean; readme: null | string }) { if (isLoading) { return ; } diff --git a/src/routes/BundleDetail.tsx b/src/routes/BundleDetail.tsx index d215e03..2af2dd1 100644 --- a/src/routes/BundleDetail.tsx +++ b/src/routes/BundleDetail.tsx @@ -18,7 +18,6 @@ import { useBundleManifest } from '@/hooks/useBundleManifest'; import { useDownloadBundle } from '@/hooks/useDownloadBundle'; import { refKey as manifestRefKey, useManifestGraph } from '@/hooks/useManifestGraph'; import { useRegistry } from '@/hooks/useRegistry'; -import { listAssetFiles } from '@/lib/file-list'; import { type AssetManifestRef } from '@/lib/registry-client'; import { RegistryNotFoundError } from '@/lib/registry-errors'; import { bumpVersion } from '@/lib/version-utils'; @@ -26,7 +25,11 @@ import { bumpVersion } from '@/lib/version-utils'; export function BundleDetailRoute() { // Global bundles route as `/bundles/:bundleId`; org-scoped bundles as // `/bundles/:org/:name` (bare org, no `@` in the URL — §2 convention). - const { bundleId, name: nameParam, org: orgParam } = useParams<{ + const { + bundleId, + name: nameParam, + org: orgParam, + } = useParams<{ bundleId?: string; name?: string; org?: string; @@ -58,6 +61,7 @@ export function BundleDetailRoute() { author: bundle.author, description: bundle.description, name: bundle.name, + ...(bundle.org ? { org: bundle.org } : {}), setupInstructions: bundle.setupInstructions, tags: bundle.tags, version: safeBumpMinor(bundle.version), @@ -93,10 +97,7 @@ export function BundleDetailRoute() { () => collectMemberRefs(manifestQuery.data, memberVersions), [manifestQuery.data, memberVersions], ); - const memberKeys = useMemo( - () => new Set(memberRefs.map((ref) => manifestRefKey(ref))), - [memberRefs], - ); + const memberKeys = useMemo(() => new Set(memberRefs.map((ref) => manifestRefKey(ref))), [memberRefs]); const manifestGraph = useManifestGraph(memberRefs); if (!bundleName) { @@ -172,7 +173,6 @@ export function BundleDetailRoute() { void download(manifest.name, { format, org: bundleOrg, - resolveVersion, version: manifest.version, }) } @@ -217,7 +217,14 @@ export function BundleDetailRoute() { @@ -262,13 +269,15 @@ function buildBundleFileGroups( memberRefs: AssetManifestRef[], memberKeys: Set, manifests: Map, + files: Map, order: string[], ): FileGroup[] { const primaryFiles: string[] = ['bundle.json']; for (const ref of memberRefs) { - const member = manifests.get(manifestRefKey(ref)); + const key = manifestRefKey(ref); + const member = manifests.get(key); if (!member) continue; - for (const path of listAssetFiles(member)) { + for (const path of files.get(key) ?? []) { primaryFiles.push(`${member.name}/${path}`); } } @@ -284,7 +293,7 @@ function buildBundleFileGroups( const dep = manifests.get(key); if (!dep) continue; groups.push({ - files: listAssetFiles(dep), + files: files.get(key) ?? [], name: dep.name, testId: `files-group-${dep.name}-${dep.version}`, version: dep.version, @@ -319,13 +328,7 @@ function BundleMemberCard({ - - {version - ? `v${version}` - : member.org - ? `not found in org '${member.org}'` - : 'version unresolved'} - + {version ? `v${version}` : member.org ? `not found in org '${member.org}'` : 'version unresolved'} {member.org ? org: {member.org} : null} diff --git a/src/routes/Bundles.tsx b/src/routes/Bundles.tsx index 352bd09..caf85ed 100644 --- a/src/routes/Bundles.tsx +++ b/src/routes/Bundles.tsx @@ -10,11 +10,11 @@ import { } from '@tanstack/react-table'; import { Building2, Columns3, Hash, X } from 'lucide-react'; import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import type { DownloadFormat } from '@/lib/download-service'; -import type { RegistryAsset, RegistryBundle } from '@/lib/schemas/registry'; +import type { RegistryBundle } from '@/lib/schemas/registry'; import { DownloadMenu } from '@/components/DownloadMenu'; import { EmptyState } from '@/components/EmptyState'; @@ -46,16 +46,7 @@ interface BundleRow { version: string; } -const ALL_COLUMN_IDS = [ - 'name', - 'version', - 'author', - 'description', - 'tags', - 'org', - 'assetCount', - 'actions', -] as const; +const ALL_COLUMN_IDS = ['name', 'version', 'author', 'description', 'tags', 'org', 'assetCount', 'actions'] as const; type ColumnId = (typeof ALL_COLUMN_IDS)[number]; const COLUMN_VISIBILITY_STORAGE_KEY = 'atk.bundles.columnVisibility'; @@ -97,10 +88,7 @@ export function BundlesRoute() { const [columnVisibility, setColumnVisibility] = useState(loadColumnVisibility); useEffect(() => { try { - window.localStorage.setItem( - COLUMN_VISIBILITY_STORAGE_KEY, - JSON.stringify(columnVisibility), - ); + window.localStorage.setItem(COLUMN_VISIBILITY_STORAGE_KEY, JSON.stringify(columnVisibility)); } catch { /* ignore persistence errors */ } @@ -116,20 +104,6 @@ export function BundlesRoute() { }, [showOrgScoped]); const bundles = useMemo(() => data?.bundles ?? [], [data]); - const assets = useMemo(() => data?.assets ?? [], [data]); - - const resolveVersion = useCallback( - (member: { name: string; org?: string; type: string }) => { - const match = assets.find( - (asset) => - asset.name === member.name && - asset.type === member.type && - (asset.org ?? undefined) === member.org, - ); - return match?.latest; - }, - [assets], - ); const { allOrgs, allTags } = useMemo(() => { const tags = new Set(); @@ -238,7 +212,6 @@ export function BundlesRoute() { void download(row.original.name, { format, org: bundleOrg, - resolveVersion, version: row.original.version, }) } @@ -252,7 +225,7 @@ export function BundlesRoute() { id: 'actions', }, ], - [download, isDownloading, resolveVersion], + [download, isDownloading], ); const table = useReactTable({ @@ -303,12 +276,7 @@ export function BundlesRoute() { <> navigate('/bundles/new')} - size='sm' - type='button' - > + } @@ -361,13 +329,7 @@ export function BundlesRoute() { Show org-scoped bundles {hasActiveFilters ? ( - ) : null} @@ -420,7 +382,6 @@ export function BundlesRoute() { void download(row.name, { format, org: row.org || undefined, - resolveVersion, version: row.version, }) } diff --git a/src/routes/Contribute.tsx b/src/routes/Contribute.tsx index f81fa67..137def6 100644 --- a/src/routes/Contribute.tsx +++ b/src/routes/Contribute.tsx @@ -7,6 +7,7 @@ import { useWideLayout } from '@/components/layout/LayoutWidthContext'; import { LoadingIndicator } from '@/components/LoadingIndicator'; import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { PageHeader } from '@/components/PageHeader'; +import { PublishIssuesPanel } from '@/components/PublishIssuesPanel'; import { SectionHeader } from '@/components/SectionHeader'; import { Stepper, type StepperStep } from '@/components/Stepper'; import { ConfirmDialog } from '@/components/ui/alert-dialog'; @@ -18,15 +19,10 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { useSession } from '@/hooks/useSession'; import { useToast } from '@/hooks/useToast'; -import { - basename, - type FileEntry, - fileToFileEntry, - isBinaryEntry, - stripCommonRoot, -} from '@/lib/file-entry'; +import { type ApiErrorDetail } from '@/lib/api-client'; +import { basename, type FileEntry, fileToFileEntry, isBinaryEntry, stripCommonRoot } from '@/lib/file-entry'; import { parseFrontmatter } from '@/lib/frontmatter'; -import { PublishError } from '@/lib/publish-errors'; +import { PublishError, PublishValidationError } from '@/lib/publish-errors'; import { publishContribution, type PublishProgressEvent } from '@/lib/publish-service'; import { fetchRegistry, findExistingAsset } from '@/lib/registry-client'; import { AssetType, type Manifest, ManifestSchema } from '@/lib/schemas/manifest'; @@ -96,7 +92,11 @@ const PersistedDraftSchema = z.object({ name: z.string(), org: z.string(), readme: z.string(), - step: z.number().int().min(0).max(STEPS.length - 1), + step: z + .number() + .int() + .min(0) + .max(STEPS.length - 1), tags: z.array(z.string()), type: z.union([z.literal(''), AssetType]), version: z.string(), @@ -109,6 +109,8 @@ interface StepProps { interface StepReviewProps { draft: DraftState; + /** Problems reported by the registry API on the last submit attempt. */ + publishIssues: ApiErrorDetail[]; validation: z.ZodSafeParseResult; } @@ -152,10 +154,7 @@ export function clearDraftFromStorage(): void { window.sessionStorage.removeItem(DRAFT_STORAGE_KEY); } -export function computeVersionConflict( - draft: DraftState, - registry: null | Registry, -): VersionConflictState { +export function computeVersionConflict(draft: DraftState, registry: null | Registry): VersionConflictState { if (!registry) return { status: 'none' }; if (!draft.name || !draft.type || !isValidSemver(draft.version)) { return { status: 'none' }; @@ -174,7 +173,7 @@ export function computeVersionConflict( export function ContributeRoute() { useWideLayout(); - const { octokit, user } = useSession(); + const { api, user } = useSession(); const toast = useToast(); const navigate = useNavigate(); const location = useLocation(); @@ -182,6 +181,7 @@ export function ContributeRoute() { const [draft, setDraft] = useState(() => createInitialDraft(defaultAuthor)); const [submitting, setSubmitting] = useState(false); const [progress, setProgress] = useState(null); + const [publishIssues, setPublishIssues] = useState([]); const [registry, setRegistry] = useState(null); const [resetDialogOpen, setResetDialogOpen] = useState(false); const hydratedRef = useRef(false); @@ -218,11 +218,11 @@ export function ContributeRoute() { useEffect(() => { if (draft.step !== 2) return; if (registry) return; - if (!octokit) return; + if (!api) return; if (registryFetchStartedRef.current) return; registryFetchStartedRef.current = true; let cancelled = false; - fetchRegistry({ octokit }) + fetchRegistry({ client: api }) .then((r) => { if (!cancelled) setRegistry(r); }) @@ -232,7 +232,7 @@ export function ContributeRoute() { return () => { cancelled = true; }; - }, [draft.step, registry, octokit]); + }, [draft.step, registry, api]); useEffect(() => { setDraft((prev) => { @@ -274,7 +274,7 @@ export function ContributeRoute() { const result = validateDraft(draft); if (!result.success) return; if (submitting) return; - if (!octokit || !user) { + if (!api || !user) { toast.add({ description: 'You need to be signed in to submit a contribution.', priority: 'high', @@ -284,14 +284,15 @@ export function ContributeRoute() { } setSubmitting(true); - setProgress({ message: 'Preparing your workspace', step: 'preparing-workspace' }); + setPublishIssues([]); + setProgress({ message: 'Preparing your contribution', step: 'preparing-workspace' }); try { const publishResult = await publishContribution({ + client: api, dryRun, files: draft.files.map((f) => ({ content: f.content, encoding: f.encoding, path: f.path })), manifest: result.data, - octokit, onProgress: (event) => setProgress(event), readme: draft.readme, }); @@ -304,9 +305,11 @@ export function ContributeRoute() { branchName: publishResult.branchName, dryRun: publishResult.dryRun, prUrl: publishResult.prUrl, + warnings: publishResult.warnings, }, }); } catch (error) { + if (error instanceof PublishValidationError) setPublishIssues(error.details); const message = error instanceof PublishError ? error.userMessage @@ -342,7 +345,7 @@ export function ContributeRoute() { {draft.step === 1 && } {draft.step === 2 && } {draft.step === 3 && } - {draft.step === 4 && } + {draft.step === 4 && } {submitting && progress ? (
| undefined { - const manifestFile = files.find( - (f) => !isBinaryEntry(f) && basename(f.path).toLowerCase() === 'manifest.json', - ); + const manifestFile = files.find((f) => !isBinaryEntry(f) && basename(f.path).toLowerCase() === 'manifest.json'); if (!manifestFile) return undefined; try { const parsed = JSON.parse(manifestFile.content) as Record; @@ -473,9 +474,7 @@ function extractManifest(files: FileEntry[]): Partial | undefined { } function extractReadme(files: FileEntry[]): string | undefined { - const readme = files.find( - (f) => !isBinaryEntry(f) && basename(f.path).toLowerCase() === 'readme.md', - ); + const readme = files.find((f) => !isBinaryEntry(f) && basename(f.path).toLowerCase() === 'readme.md'); return readme?.content; } @@ -545,8 +544,7 @@ async function readDropEntries(dataTransfer: DataTransfer): Promise async function readFileList(fileList: FileList): Promise { const entries: FileEntry[] = []; for (const file of Array.from(fileList)) { - const path = - (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; + const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; entries.push(await fileToFileEntry(file, path)); } return stripCommonRoot(entries); @@ -809,7 +807,11 @@ function StepMetadata({ draft, onChange }: StepProps) { setTagDraft(''); }; - const removeTag = (tag: string) => onChange('tags', draft.tags.filter((t) => t !== tag)); + const removeTag = (tag: string) => + onChange( + 'tags', + draft.tags.filter((t) => t !== tag), + ); return ( @@ -1001,7 +1003,7 @@ function StepReadme({ draft, onChange }: StepProps) { ); } -function StepReview({ draft, validation }: StepReviewProps) { +function StepReview({ draft, publishIssues, validation }: StepReviewProps) { const manifestInput = buildManifestInput(draft); return ( @@ -1049,6 +1051,7 @@ function StepReview({ draft, validation }: StepReviewProps) { )}
+
{validation.success ? (

@@ -1157,35 +1160,17 @@ function VersionConflictPanel({

{patchPreview ? ( - ) : null} {minorPreview ? ( - ) : null} {majorPreview ? ( - ) : null} @@ -1206,9 +1191,7 @@ async function walkFsEntry(entry: FileSystemEntry): Promise { const collected: FileEntry[] = []; // readEntries returns batches; loop until the reader signals completion with an empty batch. for (;;) { - const batch = await new Promise((resolve, reject) => - reader.readEntries(resolve, reject), - ); + const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); if (batch.length === 0) break; for (const child of batch) { const childEntries = await walkFsEntry(child); @@ -1264,4 +1247,3 @@ function WizardNav({
); } - diff --git a/src/routes/ContributeSuccess.tsx b/src/routes/ContributeSuccess.tsx index e142636..2b5c1af 100644 --- a/src/routes/ContributeSuccess.tsx +++ b/src/routes/ContributeSuccess.tsx @@ -12,6 +12,8 @@ export interface ContributeSuccessState { branchName?: string; dryRun?: boolean; prUrl: string; + /** Non-blocking advice from the registry's validation. */ + warnings?: string[]; } export function ContributeSuccessRoute() { @@ -73,6 +75,19 @@ export function ContributeSuccessRoute() { Branch: {state.branchName}

) : null} + {state.warnings && state.warnings.length > 0 ? ( +
+

Suggestions from the registry

+
    + {state.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ) : null}
diff --git a/src/routes/CreateBundle.tsx b/src/routes/CreateBundle.tsx index 34508b0..55c7cef 100644 --- a/src/routes/CreateBundle.tsx +++ b/src/routes/CreateBundle.tsx @@ -7,6 +7,7 @@ import { useWideLayout } from '@/components/layout/LayoutWidthContext'; import { LoadingIndicator } from '@/components/LoadingIndicator'; import { MarkdownRenderer } from '@/components/MarkdownRenderer'; import { PageHeader } from '@/components/PageHeader'; +import { PublishIssuesPanel } from '@/components/PublishIssuesPanel'; import { SectionHeader } from '@/components/SectionHeader'; import { Stepper, type StepperStep } from '@/components/Stepper'; import { ConfirmDialog } from '@/components/ui/alert-dialog'; @@ -19,7 +20,8 @@ import { Textarea } from '@/components/ui/textarea'; import { useRegistry } from '@/hooks/useRegistry'; import { useSession } from '@/hooks/useSession'; import { useToast } from '@/hooks/useToast'; -import { PublishError } from '@/lib/publish-errors'; +import { type ApiErrorDetail } from '@/lib/api-client'; +import { PublishError, PublishValidationError } from '@/lib/publish-errors'; import { publishBundle, type PublishProgressEvent } from '@/lib/publish-service'; import { findExistingBundle } from '@/lib/registry-client'; import { type Bundle, BundleSchema } from '@/lib/schemas/bundle'; @@ -42,6 +44,8 @@ export interface BundleDraftState { author: string; description: string; name: string; + /** Bare org name (no `@`) for an org-scoped bundle; empty for a global bundle. */ + org: string; setupInstructions: string; step: number; tags: string[]; @@ -55,6 +59,7 @@ export interface CreateBundleSeed { author: string; description: string; name: string; + org?: string; setupInstructions?: string; tags?: string[]; version: string; @@ -73,6 +78,7 @@ const STEPS: StepperStep[] = [ ]; const KEBAB_CASE_REGEX = /^[a-z][a-z0-9-]*(? 0) bundle.tags = draft.tags; if (draft.setupInstructions.trim().length > 0) bundle.setupInstructions = draft.setupInstructions; return bundle; @@ -139,13 +152,10 @@ export function clearBundleDraftFromStorage(): void { window.sessionStorage.removeItem(DRAFT_STORAGE_KEY); } -export function computeBundleVersionConflict( - draft: BundleDraftState, - registry: null | Registry, -): VersionConflictState { +export function computeBundleVersionConflict(draft: BundleDraftState, registry: null | Registry): VersionConflictState { if (!registry) return { status: 'none' }; if (!draft.name || !isValidSemver(draft.version)) return { status: 'none' }; - const found = findExistingBundle(registry, { name: draft.name }); + const found = findExistingBundle(registry, { name: draft.name, org: draft.org || undefined }); if (!found) return { status: 'none' }; const cmp = semverCompare(draft.version, found.latest); return cmp > 0 @@ -155,7 +165,7 @@ export function computeBundleVersionConflict( export function CreateBundleRoute() { useWideLayout(); - const { octokit, user } = useSession(); + const { api, user } = useSession(); const toast = useToast(); const navigate = useNavigate(); const location = useLocation(); @@ -168,6 +178,7 @@ export function CreateBundleRoute() { const [draft, setDraft] = useState(() => createInitialBundleDraft(defaultAuthor)); const [submitting, setSubmitting] = useState(false); const [progress, setProgress] = useState(null); + const [publishIssues, setPublishIssues] = useState([]); const [resetDialogOpen, setResetDialogOpen] = useState(false); const hydratedRef = useRef(false); const skipNextPersistRef = useRef(false); @@ -190,6 +201,7 @@ export function CreateBundleRoute() { author: seed.author || defaultAuthor, description: seed.description, name: seed.name, + org: seed.org ?? '', setupInstructions: seed.setupInstructions ?? '', tags: seed.tags ?? [], version: seed.version, @@ -222,7 +234,7 @@ export function CreateBundleRoute() { } return { ...prev, versionConflict: next }; }); - }, [registry, draft.name, draft.version]); + }, [registry, draft.name, draft.org, draft.version]); const update = (key: K, value: BundleDraftState[K]) => { setDraft((prev) => ({ ...prev, [key]: value })); @@ -253,7 +265,7 @@ export function CreateBundleRoute() { const result = validateBundleDraft(draft); if (!result.success) return; if (submitting) return; - if (!octokit || !user) { + if (!api || !user) { toast.add({ description: 'You need to be signed in to publish a bundle.', priority: 'high', @@ -263,13 +275,14 @@ export function CreateBundleRoute() { } setSubmitting(true); - setProgress({ message: 'Preparing your workspace', step: 'preparing-workspace' }); + setPublishIssues([]); + setProgress({ message: 'Preparing your contribution', step: 'preparing-workspace' }); try { const publishResult = await publishBundle({ bundle: result.data, + client: api, dryRun, - octokit, onProgress: (event) => setProgress(event), readme: '', }); @@ -282,9 +295,11 @@ export function CreateBundleRoute() { branchName: publishResult.branchName, dryRun: publishResult.dryRun, prUrl: publishResult.prUrl, + warnings: publishResult.warnings, }, }); } catch (error) { + if (error instanceof PublishValidationError) setPublishIssues(error.details); const message = error instanceof PublishError ? error.userMessage @@ -314,15 +329,10 @@ export function CreateBundleRoute() {
{draft.step === 0 && } {draft.step === 1 && ( - + )} {draft.step === 2 && } - {draft.step === 3 && } + {draft.step === 3 && } {submitting && progress ? (
0 && + (!draft.org || ORG_REGEX.test(draft.org)) && isValidSemver(draft.version) && draft.author.trim().length > 0 && draft.versionConflict.status !== 'conflict'; @@ -447,7 +458,10 @@ function StepAssets({ }; const removeAsset = (key: string) => { - onChange('assets', draft.assets.filter((a) => assetKey(a) !== key)); + onChange( + 'assets', + draft.assets.filter((a) => assetKey(a) !== key), + ); }; const setMemberVersion = (key: string, version: string | undefined) => { @@ -586,6 +600,7 @@ function StepAssets({ function StepMetadata({ draft, onChange }: StepProps) { const [tagDraft, setTagDraft] = useState(''); const nameValid = !draft.name || KEBAB_CASE_REGEX.test(draft.name); + const orgValid = !draft.org || ORG_REGEX.test(draft.org); const versionValid = draft.version === '' || isValidSemver(draft.version); const addTag = () => { @@ -598,7 +613,11 @@ function StepMetadata({ draft, onChange }: StepProps) { onChange('tags', [...draft.tags, cleaned]); setTagDraft(''); }; - const removeTag = (tag: string) => onChange('tags', draft.tags.filter((t) => t !== tag)); + const removeTag = (tag: string) => + onChange( + 'tags', + draft.tags.filter((t) => t !== tag), + ); return ( @@ -667,16 +686,42 @@ function StepMetadata({ draft, onChange }: StepProps) { value={draft.description} />
-
- - onChange('author', event.target.value)} - placeholder='GitHub login' - value={draft.author} - /> -

Pre-filled from your GitHub session; edit if needed.

+
+
+ + onChange('org', event.target.value)} + placeholder='my-org' + value={draft.org} + /> +
+ {!orgValid ? ( +

+ Org must start with a letter and contain only letters, digits, and hyphens. +

+ ) : ( +

+ Leave blank for a global bundle. An org bundle may only include its own org's assets and global + assets. +

+ )} +
+
+
+ + onChange('author', event.target.value)} + placeholder='GitHub login' + value={draft.author} + /> +

Pre-filled from your GitHub session; edit if needed.

+
@@ -724,9 +769,12 @@ function StepMetadata({ draft, onChange }: StepProps) { function StepReview({ draft, + publishIssues, validation, }: { draft: BundleDraftState; + /** Problems reported by the registry API on the last submit attempt. */ + publishIssues: ApiErrorDetail[]; validation: z.ZodSafeParseResult; }) { const bundleInput = buildBundleInput(draft); @@ -756,6 +804,7 @@ function StepReview({ {JSON.stringify(bundleInput, null, 2)}
+
{validation.success ? (

@@ -909,7 +958,13 @@ function WizardNav({ }) { return (

-
diff --git a/src/routes/CreateBundleSuccess.tsx b/src/routes/CreateBundleSuccess.tsx index 26e3b26..3323ea6 100644 --- a/src/routes/CreateBundleSuccess.tsx +++ b/src/routes/CreateBundleSuccess.tsx @@ -12,6 +12,8 @@ export interface CreateBundleSuccessState { branchName?: string; dryRun?: boolean; prUrl: string; + /** Non-blocking advice from the registry's validation. */ + warnings?: string[]; } export function CreateBundleSuccessRoute() { @@ -73,6 +75,19 @@ export function CreateBundleSuccessRoute() { Branch: {state.branchName}

) : null} + {state.warnings && state.warnings.length > 0 ? ( +
+

Suggestions from the registry

+
    + {state.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ) : null}
diff --git a/src/setupTests.ts b/src/setupTests.ts index c552e1b..8075cc1 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,4 +3,4 @@ import { vi } from 'vitest'; // Provide deterministic Vite env values for tests that exercise the session layer. vi.stubEnv('VITE_GITHUB_OAUTH_CLIENT_ID', 'test-client-id'); -vi.stubEnv('VITE_AUTH_FUNCTION_URL', 'http://localhost:7071'); +vi.stubEnv('VITE_ATK_API_URL', 'http://localhost:7071'); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 660fd8b..a867922 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,6 @@ interface ImportMeta { } interface ImportMetaEnv { - readonly VITE_AUTH_FUNCTION_URL?: string; + readonly VITE_ATK_API_URL?: string; readonly VITE_GITHUB_OAUTH_CLIENT_ID?: string; }