diff --git a/.claude/skills/add-resource/SKILL.md b/.claude/skills/add-resource/SKILL.md index 38a8f7d..a683eed 100644 --- a/.claude/skills/add-resource/SKILL.md +++ b/.claude/skills/add-resource/SKILL.md @@ -17,6 +17,39 @@ The user wants to manage a new PostHog resource as code, e.g.: If the user only wants a one-off API call or a script, this is the wrong tool — this skill is for resources that should join the `load → validate → diff → execute` pipeline. +## Start with the scaffolder + +Don't hand-write the 5 files. Run: + +``` +pnpm scaffold-resource \ + --name # e.g. cohort, action, survey + --path # e.g. /api/projects/{project_id}/cohorts/ + --key-field # field that identifies the resource (default: name) +``` + +The script reads `../posthog/frontend/tmp/openapi.json` and generates `src/resources//{client,sdk,pipeline,pipeline.test,index}.ts`, then wires the resource into `src/resources/index.ts`, `src/index.ts`, `scripts/lib/registry.ts`, and flips the row in `docs/resources.md`. The scaffold is implemented at `scripts/scaffold-resource.ts`. + +What you get out of the box: + +- **`client.ts`** — ~95% complete. Zod `ServerXSchema` generated from the OpenAPI response component, `ListPaginatedSchema`, CRUD wrappers, and a `listManagedX` that filters by `iac::` tags (or marks a TODO if the resource has no `tags` field). +- **`sdk.ts`** — a starter `X` type from the create-request schema plus the factory with `markResourceKind(spec, "x")`. **Almost always needs narrowing** — the OpenAPI request body usually exposes more than the IaC layer should. +- **`pipeline.ts`** — full structural skeleton (`xTag`, `xKeyFromTags`, `xHash`, `xPayload`, `validateXs`, `runXOp`, `pruneX`, display helpers) with `TODO(human)` markers over the per-resource judgment calls. +- **`pipeline.test.ts`** — 3-test skeleton (validate / hash / `looksLikeX`) plus a TODO list for the 5 op-selection + safety-invariant tests that this skill requires below. +- **`index.ts`** — the `ResourceModule` registration, fully wired. + +What you must still do (these are the `TODO(human)` markers — *the rest of this skill explains why*): + +1. **Narrow the SDK type** in `sdk.ts` — strip server-set fields, deprecated aliases, write-only options the IaC layer should not expose. +2. **Pick an identity mechanism** if the resource has no `tags` field — see the description-marker section below and `src/resources/endpoint/`. +3. **Write the hash projection** `xSpecForHash` in `pipeline.ts` — every user-intent field, no server-set fields. *This is the dangerous one* — get it wrong and every apply either rewrites unchanged rows or skips real changes. +4. **Write the create/update payload** `xPayload` — usually `spec` plus merged identity tags, but resource-specific. +5. **Tune validation** `validateXs` — at minimum key required + unique, plus resource-specific invariants. +6. **Polish display rendering** — `displayJson` is the fallback; field-by-field `scalar`/`obj`/`arr` calls give readable diffs. +7. **Add the safety-invariant tests** to `pipeline.test.ts` — the 5 cases listed in "Unit tests" below. + +Then read the rest of this skill — it is the *why* behind each TODO marker and the verification flow you still owe. + ## Before you touch anything Re-read these three files. The whole architecture is in them: @@ -312,7 +345,19 @@ The last step is the safety invariant smoke test. Do not skip it. ## Update the support matrix -After the resource ships, flip its row from ❌ to ✅ in `docs/resources.md`. +The scaffolder flips the matching row in `docs/resources.md` from ❌ to ✅ automatically. If it couldn't find a matching row (the heuristic uses the plural URL segment), flip it by hand. + +## Checking for API drift + +Run `pnpm check-resources` (implemented at `scripts/check-resources.ts`) to compare every shipped resource's `ServerXSchema` against the current OpenAPI spec. It reports three things: + +- **Added fields** — present in the OpenAPI response but not in our schema. Most of these are not worth tracking (server-set metadata is excluded by default; pass `--all` to include `readOnly` fields). The ones to act on are the user-intent fields that have appeared since this resource was scaffolded. +- **Removed fields** — present in our schema but no longer in the OpenAPI response. The most important signal: we may be reading something that no longer exists. +- **Type mismatches** — coarse `string` vs `number` vs `array` differences between our schema and the API. + +The script exits non-zero if any drift is found, so it fits straight into CI as a gate. Refresh `posthog/frontend/tmp/openapi.json` first (the PostHog repo's frontend codegen step produces it) before running. + +The registry that drives the comparison lives in `scripts/lib/registry.ts`. The scaffolder appends new entries automatically; if you add a resource by hand, add a row there too. ## Deletes diff --git a/.claude/skills/add-singleton-resource/SKILL.md b/.claude/skills/add-singleton-resource/SKILL.md index bfb79d6..9ab057d 100644 --- a/.claude/skills/add-singleton-resource/SKILL.md +++ b/.claude/skills/add-singleton-resource/SKILL.md @@ -19,6 +19,10 @@ This skill is the playbook for resources that exist exactly **once per project** If you're unsure: list the API. If you GET a single object (not a paginated list of objects), it's a singleton. If you'd need a `key` field on the spec to tell two of them apart in code, it's not. +## No scaffolder for singletons + +The `pnpm scaffold-resource` script in `scripts/scaffold-resource.ts` is collection-only — it assumes a paginated list endpoint with `iac::` tag identity. Singletons need a different shape (one declarative block, field-level diff, PATCH-only) and have no list endpoint to scaffold from, so hand-write the files following this skill. The `pnpm check-resources` drift check can still apply if you add a manifest entry pointing at the singleton's OpenAPI component, but the bigger value there is for collection resources. + ## Before you touch anything Re-read the same three architecture docs as `add-resource`: diff --git a/package.json b/package.json index 52a0614..6586faa 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "test": "vitest run", "test:watch": "vitest", "test:integration": "VITEST_MODE=integration vitest run", - "test:acceptance": "VITEST_MODE=acceptance vitest run" + "test:acceptance": "VITEST_MODE=acceptance vitest run", + "scaffold-resource": "tsx scripts/scaffold-resource.ts", + "check-resources": "tsx scripts/check-resources.ts" }, "dependencies": { "enquirer": "^2.4.1", @@ -45,6 +47,7 @@ "@types/node": "^22.10.2", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", + "openapi-zod-client": "^1.18.3", "prettier": "^3.8.3", "typescript": "^5.7.2", "typescript-eslint": "^8.59.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5b19de..fe7a85b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@10.3.0) + openapi-zod-client: + specifier: ^1.18.3 + version: 1.18.3 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -48,6 +51,89 @@ importers: packages: + '@apidevtools/json-schema-ref-parser@11.7.2': + resolution: {integrity: sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@10.1.1': + resolution: {integrity: sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==} + peerDependencies: + openapi-types: '>=7' + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -401,9 +487,28 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + + '@liuli-util/fs-extra@0.1.0': + resolution: {integrity: sha512-eaAyDyMGT23QuRGbITVY3SOJff3G9ekAAyGqB9joAnTBmqvFN+9a1FazOdO70G6IUqgpKV451eBHYSRcOJ/FNQ==} + '@rollup/rollup-android-arm-eabi@4.60.3': resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} cpu: [arm] @@ -551,6 +656,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -645,6 +753,12 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@zodios/core@10.9.6': + resolution: {integrity: sha512-aH4rOdb3AcezN7ws8vDgBfGboZMk2JGGzEq/DtW65MhnRxyTGRuLJRWVQ/2KxDgWvV2F5oTkAS+5pnjKbl0n+A==} + peerDependencies: + axios: ^0.x || ^1.0.0 + zod: ^3.x + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -655,9 +769,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -666,22 +791,51 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 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'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + engines: {node: '>=6.0.0'} + hasBin: true + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -690,6 +844,13 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -710,13 +871,40 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.352: + resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -727,6 +915,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -782,6 +974,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eval-estree-expression@3.0.1: + resolution: {integrity: sha512-zTLKGbiVdQYp4rQkSoXPibrFf5ZoPn6jzExegRLEQ13F+FSxu5iLgaRH6hlDs2kWSUa6vp8yD20cdJi0me6pEw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -795,6 +990,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -819,11 +1017,43 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -831,6 +1061,30 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -854,15 +1108,38 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + 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-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -877,13 +1154,31 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -895,6 +1190,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi-zod-client@1.18.3: + resolution: {integrity: sha512-10vYK7xo1yyZfcoRvYNGIsDeej1CG9k63u8dkjbGBlr+NHZMy2Iy2h9s11UWNKdj6XMDWbNOPp5gIy8YdpgPtQ==} + hasBin: true + + openapi3-ts@3.1.0: + resolution: {integrity: sha512-1qKTvCCVoV0rkwUh1zq5o8QyghmwYPuhdvtjv1rFjuOnJToXhQyF8eGjNETQ8QmGjr9Jz/tkAKLITIl2s7dw3A==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -907,6 +1218,18 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pastable@2.2.1: + resolution: {integrity: sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==} + engines: {node: '>=14.x'} + peerDependencies: + react: '>=17' + xstate: '>=4.32.1' + peerDependenciesMeta: + react: + optional: true + xstate: + optional: true + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -937,15 +1260,28 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -954,6 +1290,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -974,6 +1314,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -984,6 +1328,9 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + tanu@0.1.13: + resolution: {integrity: sha512-UbRmX7ccZ4wMVOY/Uw+7ji4VOkEYSYJG1+I4qzbnn4qh/jtvVbrm6BFnF12NQQ4+jGv21wKmjb1iFyUSVnBWcQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1012,6 +1359,15 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-pattern@5.9.0: + resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + + ts-toolbelt@9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -1021,6 +1377,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@3.13.1: + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} + typescript-eslint@8.59.2: resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1028,14 +1388,34 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1100,6 +1480,10 @@ packages: jsdom: optional: true + whence@2.1.0: + resolution: {integrity: sha512-4UBPMg5mng5KLzdliVQdQ4fJwCdIMXkE8CkoDmGKRy5r8pV9xq+nVgf/sKXpmNEIOtFp7m7v2bFdb7JoLvh+Hg==} + engines: {node: '>=14'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1114,15 +1498,150 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: + '@apidevtools/json-schema-ref-parser@11.7.2': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@10.1.1(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 11.7.2 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1320,8 +1839,32 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsdevtools/ono@7.1.3': {} + + '@liuli-util/fs-extra@0.1.0': + dependencies: + '@types/fs-extra': 9.0.13 + fs-extra: 10.1.0 + '@rollup/rollup-android-arm-eabi@4.60.3': optional: true @@ -1403,6 +1946,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 22.19.17 + '@types/json-schema@7.0.15': {} '@types/node@22.19.17': @@ -1540,12 +2087,21 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@zodios/core@10.9.6(axios@1.16.0)(zod@3.25.76)': + dependencies: + axios: 1.16.0 + zod: 3.25.76 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -1553,20 +2109,58 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + argparse@2.0.1: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.27: {} + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.352 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-me-maybe@1.0.2: {} + + caniuse-lite@1.0.30001792: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1577,6 +2171,12 @@ snapshots: check-error@2.1.3: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1591,13 +2191,38 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.352: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1653,6 +2278,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@10.3.0): @@ -1727,6 +2354,8 @@ snapshots: esutils@2.0.3: {} + eval-estree-expression@3.0.1: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -1735,6 +2364,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.2: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1755,9 +2386,47 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -1766,6 +2435,29 @@ snapshots: dependencies: is-glob: 4.0.3 + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -1780,12 +2472,30 @@ snapshots: isexe@2.0.0: {} + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -1801,20 +2511,66 @@ snapshots: loupe@3.2.1: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 + minimist@1.2.8: {} + ms@2.1.3: {} nanoid@3.3.12: {} natural-compare@1.4.0: {} + neo-async@2.6.2: {} + + node-releases@2.0.38: {} + + openapi-types@12.1.3: {} + + openapi-zod-client@1.18.3: + dependencies: + '@apidevtools/swagger-parser': 10.1.1(openapi-types@12.1.3) + '@liuli-util/fs-extra': 0.1.0 + '@zodios/core': 10.9.6(axios@1.16.0)(zod@3.25.76) + axios: 1.16.0 + cac: 6.7.14 + handlebars: 4.7.9 + openapi-types: 12.1.3 + openapi3-ts: 3.1.0 + pastable: 2.2.1 + prettier: 2.8.8 + tanu: 0.1.13 + ts-pattern: 5.9.0 + whence: 2.1.0 + zod: 3.25.76 + transitivePeerDependencies: + - debug + - react + - supports-color + - xstate + + openapi3-ts@3.1.0: + dependencies: + yaml: 2.8.4 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1832,6 +2588,14 @@ snapshots: dependencies: p-limit: 3.1.0 + pastable@2.2.1: + dependencies: + '@babel/core': 7.29.0 + ts-toolbelt: 9.6.0 + type-fest: 3.13.1 + transitivePeerDependencies: + - supports-color + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -1852,10 +2616,16 @@ snapshots: prelude-ls@1.2.1: {} + prettier@2.8.8: {} + prettier@3.8.3: {} + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} rollup@4.60.3: @@ -1889,6 +2659,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 + semver@6.3.1: {} + semver@7.7.4: {} shebang-command@2.0.0: @@ -1901,6 +2673,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: {} + stackback@0.0.2: {} std-env@3.10.0: {} @@ -1909,6 +2683,11 @@ snapshots: dependencies: ansi-regex: 5.0.1 + tanu@0.1.13: + dependencies: + tslib: 2.8.1 + typescript: 4.9.5 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1928,6 +2707,12 @@ snapshots: dependencies: typescript: 5.9.3 + ts-pattern@5.9.0: {} + + ts-toolbelt@9.6.0: {} + + tslib@2.8.1: {} + tsx@4.21.0: dependencies: esbuild: 0.27.7 @@ -1939,6 +2724,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@3.13.1: {} + typescript-eslint@8.59.2(eslint@10.3.0)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@5.9.3))(eslint@10.3.0)(typescript@5.9.3) @@ -1950,10 +2737,23 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@4.9.5: {} + typescript@5.9.3: {} + uglify-js@3.19.3: + optional: true + undici-types@6.21.0: {} + universalify@2.0.1: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2020,6 +2820,11 @@ snapshots: - supports-color - terser + whence@2.1.0: + dependencies: + '@babel/parser': 7.29.3 + eval-estree-expression: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2031,6 +2836,14 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + + yallist@3.1.1: {} + + yaml@2.8.4: {} + yocto-queue@0.1.0: {} + zod@3.25.76: {} + zod@4.4.3: {} diff --git a/scripts/check-resources.ts b/scripts/check-resources.ts new file mode 100644 index 0000000..3b1bc0c --- /dev/null +++ b/scripts/check-resources.ts @@ -0,0 +1,326 @@ +#!/usr/bin/env tsx +/** + * Check each shipped resource's `ServerXSchema` against the current PostHog + * OpenAPI spec and report drift: + * + * - Fields the API has that our Zod schema doesn't know about (likely new). + * - Fields our Zod schema declares that the API no longer returns (removed + * server-side; we may be relying on them). + * - Coarse type mismatches (string vs number, nullable vs not, etc). + * + * Run after a `pnpm openapi:fetch` (or whatever refreshes the OpenAPI dump). + * Exits non-zero if drift is found, so this can gate CI. + */ +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; +import { componentFields, loadOpenAPI, type FieldInfo, type OpenAPIDoc } from "./lib/openapi.js"; +import { REGISTRY } from "./lib/registry.js"; + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = join(dirname(__filename), ".."); + +type Drift = { + resource: string; + added: FieldInfo[]; // in OpenAPI, not in our Zod + removed: string[]; // in our Zod, not in OpenAPI + typeMismatch: Array<{ field: string; expected: string; actual: string }>; +}; + +async function main(): Promise { + const openapiPath = process.argv.find((a) => a.startsWith("--openapi="))?.slice(11); + const doc = loadOpenAPI(openapiPath); + const json = process.argv.includes("--json"); + const includeReadOnly = process.argv.includes("--all"); + + const drifts: Drift[] = []; + for (const entry of REGISTRY) { + const drift = await checkResource(entry.name, entry.responseComponent, doc, { + includeReadOnly, + }); + drifts.push(drift); + } + + if (json) { + console.log(JSON.stringify(drifts, null, 2)); + } else { + renderHuman(drifts, includeReadOnly); + } + + // Only fail CI on user-relevant drift (added user fields, removed fields, type mismatches). + // ReadOnly noise is informational unless --all is passed. + const hasDrift = drifts.some( + (d) => d.added.length > 0 || d.removed.length > 0 || d.typeMismatch.length > 0, + ); + process.exit(hasDrift ? 1 : 0); +} + +async function checkResource( + name: string, + componentName: string, + doc: OpenAPIDoc, + options: { includeReadOnly: boolean }, +): Promise { + const allApiFields = componentFields(doc, componentName).filter((f) => !f.writeOnly); + const apiFields = options.includeReadOnly + ? allApiFields + : allApiFields.filter((f) => !f.readOnly); + // Type-mismatch comparison still uses the full set so we catch overlap. + const apiByName = new Map(allApiFields.map((f) => [f.name, f])); + + const zodShape = await loadServerSchemaShape(name); + + const added: FieldInfo[] = []; + for (const f of apiFields) { + if (!zodShape.has(f.name)) added.push(f); + } + + const removed: string[] = []; + for (const zname of zodShape.keys()) { + if (!apiByName.has(zname)) removed.push(zname); + } + + const typeMismatch: Drift["typeMismatch"] = []; + for (const [zname, zinfo] of zodShape) { + const api = apiByName.get(zname); + if (!api) continue; + const expected = describeApiField(api); + const actual = zinfo.description; + if (!compatible(expected, actual)) { + typeMismatch.push({ field: zname, expected, actual }); + } + } + + return { resource: name, added, removed, typeMismatch }; +} + +type ZodFieldInfo = { description: string; optional: boolean; nullable: boolean }; + +async function loadServerSchemaShape(name: string): Promise> { + const pascal = toPascalCase(name); + const clientPath = join(REPO_ROOT, "src", "resources", name, "client.ts"); + + // Path A: try the Zod runtime schema (preferred — kept in sync via Zod's parse). + try { + const mod = (await import(`../src/resources/${name}/client.ts`)) as Record; + const schema = mod[`Server${pascal}Schema`]; + if (schema instanceof z.ZodObject) { + const shape = (schema as z.ZodObject).shape as Record; + const out = new Map(); + for (const [k, v] of Object.entries(shape)) out.set(k, describeZod(v)); + return out; + } + } catch { + // Fall through to source parsing. + } + + // Path B: parse the exported `type ServerX = { … }` directly from source. + // Brittle but adequate for the simple shapes our older resources use. + const source = readFileSync(clientPath, "utf8"); + const parsed = parseTypeLiteral(source, `Server${pascal}`); + if (!parsed) { + throw new Error( + `Cannot extract fields for ${name}: no Server${pascal}Schema (Zod) and could not parse \`type Server${pascal} = { … }\` in client.ts`, + ); + } + return parsed; +} + +function parseTypeLiteral(source: string, name: string): Map | null { + // Match `export type Name = { ... };` taking the first balanced `{...}` block. + const startRe = new RegExp(`export type ${name}\\s*=\\s*\\{`); + const m = startRe.exec(source); + if (!m) return null; + const startIdx = m.index + m[0].length - 1; + let depth = 0; + let endIdx = -1; + for (let i = startIdx; i < source.length; i++) { + const ch = source[i]; + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { endIdx = i; break; } + } + } + if (endIdx === -1) return null; + const body = source.slice(startIdx + 1, endIdx); + + const out = new Map(); + // Walk top-level only (skip nested braces). + let buf = ""; + let nest = 0; + for (const ch of body) { + if (ch === "{" || ch === "(" || ch === "[") nest++; + else if (ch === "}" || ch === ")" || ch === "]") nest--; + if (ch === ";" && nest === 0) { + const line = buf.trim(); + buf = ""; + if (!line) continue; + const fm = line.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?:\s*(.+)$/s); + if (!fm) continue; + const fname = fm[1]!; + const optional = fm[2] === "?"; + const tsType = fm[3]!.trim(); + const nullable = /\bnull\b/.test(tsType); + out.set(fname, { description: tsToKind(tsType), optional, nullable }); + continue; + } + buf += ch; + } + return out; +} + +function tsToKind(ts: string): string { + const t = ts.replace(/\s+/g, ""); + if (/^number(\|null)?$/.test(t)) return "number"; + if (/^string(\|null)?$/.test(t)) return "string"; + if (/^boolean(\|null)?$/.test(t)) return "boolean"; + if (/^Record<.*>(\|null)?$/.test(t)) return "object"; + if (/^.*\[\](\|null)?$/.test(t) || /^Array z.ZodType; removeDefault?: () => z.ZodType }; + if (typeof anyT.unwrap === "function") return anyT.unwrap(); + if (typeof anyT.removeDefault === "function") return anyT.removeDefault(); + return t; +} + +function zodKind(t: z.ZodType): string { + if (t instanceof z.ZodString) return "string"; + if (t instanceof z.ZodNumber) return "number"; + if (t instanceof z.ZodBoolean) return "boolean"; + if (t instanceof z.ZodArray) return "array"; + if (t instanceof z.ZodObject) return "object"; + if (t instanceof z.ZodRecord) return "object"; + if (t instanceof z.ZodEnum) return "enum"; + if (t instanceof z.ZodUnknown) return "unknown"; + if (t instanceof z.ZodLiteral) return "literal"; + return t.constructor.name.replace(/^Zod/, "").toLowerCase(); +} + +function describeApiField(f: FieldInfo): string { + const t = f.schema.type; + let base: string; + if (t === "integer" || t === "number") base = "number"; + else if (t === "string") base = f.schema.enum ? "enum" : "string"; + else if (t === "boolean") base = "boolean"; + else if (t === "array") base = "array"; + else if (t === "object") base = "object"; + else base = "unknown"; + return base; +} + +function compatible(expected: string, actual: string): boolean { + if (expected === actual) return true; + if (expected === "unknown" || actual === "unknown") return true; + // Enums on the API side often arrive as plain strings in our schema. + if (expected === "enum" && actual === "string") return true; + if (expected === "string" && actual === "enum") return true; + return false; +} + +function toPascalCase(name: string): string { + return name + .split(/[-_\s]+/) + .filter(Boolean) + .map((p) => p[0]!.toUpperCase() + p.slice(1)) + .join(""); +} + +function renderHuman(drifts: Drift[], includeReadOnly: boolean): void { + let totalAdded = 0; + let totalRemoved = 0; + let totalMismatch = 0; + + for (const d of drifts) { + const noChange = + d.added.length === 0 && d.removed.length === 0 && d.typeMismatch.length === 0; + if (noChange) { + console.log(` ${green("✓")} ${d.resource}: in sync`); + continue; + } + console.log(`\n${bold(d.resource)}`); + for (const f of d.added) { + const flags = [ + f.required ? "required" : null, + f.nullable ? "nullable" : null, + f.readOnly ? "readOnly" : null, + ] + .filter(Boolean) + .join(", "); + console.log( + ` ${yellow("+")} ${f.name}: ${describeApiField(f)}${flags ? ` (${flags})` : ""} — in API, not in our schema`, + ); + totalAdded++; + } + for (const name of d.removed) { + console.log(` ${red("-")} ${name} — in our schema, not in API`); + totalRemoved++; + } + for (const m of d.typeMismatch) { + console.log(` ${yellow("~")} ${m.field}: API=${m.expected} schema=${m.actual}`); + totalMismatch++; + } + } + + const summary = `\n${totalAdded} added, ${totalRemoved} removed, ${totalMismatch} type mismatches across ${drifts.length} resources.`; + if (totalAdded + totalRemoved + totalMismatch === 0) { + console.log(`\n${green("All resources are in sync with the OpenAPI spec.")}`); + } else { + console.log(summary); + } + if (!includeReadOnly) { + console.log(`(readOnly API fields excluded — pass --all to include them.)`); + } +} + +function bold(s: string): string { return process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s; } +function green(s: string): string { return process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s; } +function yellow(s: string): string { return process.stdout.isTTY ? `\x1b[33m${s}\x1b[0m` : s; } +function red(s: string): string { return process.stdout.isTTY ? `\x1b[31m${s}\x1b[0m` : s; } + +main().catch((err) => { + console.error(err); + process.exit(2); +}); diff --git a/scripts/lib/extract-zod.ts b/scripts/lib/extract-zod.ts new file mode 100644 index 0000000..fab933a --- /dev/null +++ b/scripts/lib/extract-zod.ts @@ -0,0 +1,290 @@ +/** + * Pull a single OpenAPI component (plus its transitive dependencies) out of + * the full `openapi-zod-client` codegen output. The output of the tool itself + * is too large to commit (1.6MB for the PostHog spec), so the scaffolder runs + * the CLI on demand and extracts only what each resource needs. + * + * The parser is line-oriented and depth-tracked: openapi-zod-client emits + * prettier-formatted code with top-level declarations starting at column 0, + * so we can split it into declaration blocks reliably without a full TS parser. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +type DeclKind = "const" | "type"; + +type Decl = { + name: string; + kind: DeclKind; + /** Distinct id since `const X` and `type X` can coexist. */ + id: string; + source: string; // full source text including the trailing `;` + deps: Set; // ids referenced by this declaration (after self-removal) +}; + +/** + * Synthesized helpers we don't want to pull in as dependencies. These are + * lowercase top-level consts openapi-zod-client emits for path-parameter + * unions like `const id = z.union([z.number(), z.string()]);` — never + * referenced by component schemas, but collide with field keys (`id:`). + */ +function isComponentName(name: string): boolean { + return /^[A-Z]/.test(name); +} + +/** + * Cache key: a hash of the OpenAPI file's path + mtime. Re-running the CLI + * is slow (≈5s for the PostHog spec) so we cache the parsed declaration map. + */ +const _cache = new Map>(); + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = resolve(dirname(__filename), "..", ".."); + +export function loadAllDecls(openapiPath: string): Map { + const abs = resolve(process.cwd(), openapiPath); + const stat = statSync(abs); + const key = `${abs}:${stat.mtimeMs}`; + const hit = _cache.get(key); + if (hit) return hit; + + const generated = generateOnce(abs, stat.mtimeMs); + const parsed = parseDecls(generated); + _cache.set(key, parsed); + return parsed; +} + +function generateOnce(openapiAbs: string, mtimeMs: number): string { + // Persistent cache across runs, keyed on input mtime. + const hash = createHash("sha1").update(`${openapiAbs}:${mtimeMs}`).digest("hex").slice(0, 12); + const cacheDir = join(tmpdir(), "posthog-definitions-codegen"); + mkdirSync(cacheDir, { recursive: true }); + const cached = join(cacheDir, `oz-${hash}.ts`); + + if (existsSync(cached)) return readFileSync(cached, "utf8"); + + const bin = join(REPO_ROOT, "node_modules", ".bin", "openapi-zod-client"); + if (!existsSync(bin)) { + throw new Error( + `openapi-zod-client not installed. Run: pnpm add -D openapi-zod-client`, + ); + } + execFileSync(bin, [openapiAbs, "-o", cached, "--export-schemas"], { + stdio: ["ignore", "ignore", "inherit"], + }); + return readFileSync(cached, "utf8"); +} + +/** Parse the prettier-formatted output into top-level declarations. */ +function parseDecls(source: string): Map { + const out = new Map(); + const lines = source.split("\n"); + + let i = 0; + while (i < lines.length) { + const line = lines[i]!; + const m = /^(const|type)\s+(\w+)/.exec(line); + if (!m) { i++; continue; } + const kind = m[1] as DeclKind; + const name = m[2]!; + + // Find the end of this declaration by tracking brace/paren depth. + let depth = 0; + const startIdx = i; + let endIdx = i; + let inString: '"' | "'" | "`" | null = null; + let escape = false; + + outer: for (let j = i; j < lines.length; j++) { + const ln = lines[j]!; + for (let k = 0; k < ln.length; k++) { + const ch = ln[k]!; + if (escape) { escape = false; continue; } + if (inString) { + if (ch === "\\") { escape = true; continue; } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; } + if (ch === "{" || ch === "(" || ch === "[") depth++; + else if (ch === "}" || ch === ")" || ch === "]") depth--; + } + // End: at depth 0 AND line ends with `;`. + if (depth === 0 && /;\s*$/.test(ln)) { endIdx = j; break outer; } + // Defensive: if depth bottoms out and the line ends with `}` (`type X = {...}` with no trailing `;`). + if (depth === 0 && /[}\]]\s*$/.test(ln) && j > i) { endIdx = j; break outer; } + } + + const srcLines = lines.slice(startIdx, endIdx + 1); + const src = srcLines.join("\n"); + // Drop lowercase top-level consts entirely (path-param helpers — see isComponentName). + if (kind === "const" && !isComponentName(name)) { i = endIdx + 1; continue; } + const id = `${kind}:${name}`; + out.set(id, { name, kind, id, source: src, deps: new Set() }); + i = endIdx + 1; + } + + // Resolve deps: for each declaration, scan source for component-name references. + // Match longest-first so e.g. `CohortFilterGroup` resolves before `CohortFilter`. + const componentNames = [...new Set([...out.values()].map((d) => d.name))] + .filter(isComponentName) + .sort((a, b) => b.length - a.length); + + // For each const, find which other component names it references. For type + // decls we only consider references *between types*, because the TS type + // namespace is separate and most types we'd otherwise pull in are redundant + // with what zod infers from the const decl. + for (const decl of out.values()) { + // The `z.ZodType` annotation on a recursive const requires the matching `type X`. + const lazyRefs = [...decl.source.matchAll(/z\.ZodType<(\w+)>/g)].map((m) => m[1]!); + for (const ref of lazyRefs) { + if (out.has(`type:${ref}`)) decl.deps.add(`type:${ref}`); + } + + if (decl.kind === "const") { + for (const other of componentNames) { + if (other === decl.name) continue; + const re = new RegExp(`\\b${other}\\b`); + if (!re.test(decl.source)) continue; + if (out.has(`const:${other}`)) decl.deps.add(`const:${other}`); + } + } else { + // type decl → pull in type deps (TS-only chain, only needed when a const + // pulled this type in via a `z.ZodType` annotation). + for (const other of componentNames) { + if (other === decl.name) continue; + const re = new RegExp(`\\b${other}\\b`); + if (!re.test(decl.source)) continue; + if (out.has(`type:${other}`)) decl.deps.add(`type:${other}`); + } + } + } + + return out; +} + +/** + * Return the source for `root` plus all transitively referenced declarations, + * ordered for top-down emission. Recursive cycles are tolerated because + * `openapi-zod-client` uses `z.ZodType = z.lazy(...)` for self-refs, + * which doesn't need forward declarations. + */ +export function extractSubset( + decls: Map, + rootName: string, +): { needed: string[]; order: string[] } { + const rootId = `const:${rootName}`; + if (!decls.has(rootId)) { + throw new Error(`Component not found in generated schemas: ${rootName}`); + } + const needed = new Set(); + const stack = [rootId]; + while (stack.length) { + const id = stack.pop()!; + if (needed.has(id)) continue; + needed.add(id); + const decl = decls.get(id); + if (!decl) continue; + for (const dep of decl.deps) if (!needed.has(dep)) stack.push(dep); + } + + // Topological sort: declarations without remaining deps first. + // Cycles (recursive types) get emitted in arbitrary order — `z.lazy()` covers it. + const remaining = new Map>(); + for (const id of needed) { + const decl = decls.get(id)!; + remaining.set(id, new Set([...decl.deps].filter((d) => needed.has(d) && d !== id))); + } + const order: string[] = []; + while (remaining.size) { + let progressed = false; + for (const [id, deps] of remaining) { + if (deps.size === 0) { + order.push(id); + remaining.delete(id); + for (const others of remaining.values()) others.delete(id); + progressed = true; + } + } + if (!progressed) { + // Cycle: emit remaining `type:X` decls first (they're forward-ref-safe in TS), + // then the const decls. The `const X = z.ZodType = z.lazy(...)` pattern + // handles the value-level cycle. + const remIds = [...remaining.keys()]; + remIds.sort((a, b) => (a.startsWith("type:") ? -1 : 1)); + for (const id of remIds) order.push(id); + break; + } + } + + return { needed: [...needed], order }; +} + +/** + * Build the inlined Zod source for a resource's `client.ts`. + * + * `rootName`: the OpenAPI component name (e.g. "Cohort"). + * `exportName`: what to rename the root declaration to (e.g. "ServerCohortSchema"). + */ +export function buildInlinedSchema( + openapiPath: string, + rootName: string, + exportName: string, +): { source: string; inferredTypeName: string } { + const decls = loadAllDecls(openapiPath); + const { order } = extractSubset(decls, rootName); + + const inferredTypeName = exportName.replace(/Schema$/, ""); + + const lines: string[] = []; + for (const id of order) { + const decl = decls.get(id)!; + if (id === `const:${rootName}`) { + // Rename `const Cohort = ...` → `export const ServerCohortSchema = ...` + const renamed = decl.source.replace( + new RegExp(`^const\\s+${rootName}\\b`), + `export const ${exportName}`, + ); + lines.push(makeZod4Friendly(renamed)); + lines.push(`export type ${inferredTypeName} = z.infer;`); + } else { + lines.push(makeZod4Friendly(decl.source)); + } + } + return { source: lines.join("\n\n"), inferredTypeName }; +} + +/** + * openapi-zod-client emits Zod-3-style code that the Zod 4 strict typings + * reject. Two post-processing rewrites cover the cases we hit: + * + * 1. `const X: z.ZodType = z.lazy(...)` — Zod 4 can't statically verify + * that the `z.lazy()` output is assignable to `z.ZodType` when the + * inner schema recursively references `X`. Strip the annotation, append + * `as z.ZodType` to the RHS. + * + * 2. `z.discriminatedUnion("type", [..., X])` where one of the alternatives + * is a `z.ZodType<...>` (recursive ref) doesn't satisfy `$ZodTypeDiscriminable`. + * Downgrade to `z.union([...])` — slower (linear option scan) but + * functionally equivalent for our parse-only path. + */ +function makeZod4Friendly(source: string): string { + // Pattern 1: per-declaration. Each `decl.source` is the full `const X: z.ZodType = ...;`. + // Strip the type annotation and append a cast. + const m = /^const\s+(\w+)\s*:\s*z\.ZodType<(\w+)>\s*=\s*/.exec(source); + if (m && source.endsWith(";")) { + const name = m[1]!; + const typeArg = m[2]!; + const rhs = source.slice(m[0].length, -1); // strip trailing `;` + source = `const ${name} = (${rhs}) as z.ZodType<${typeArg}>;`; + } + + // Pattern 2: discriminatedUnion → union. Naive but safe. + source = source.replace(/z\.discriminatedUnion\("[^"]*",\s*/g, "z.union("); + + return source; +} diff --git a/scripts/lib/names.ts b/scripts/lib/names.ts new file mode 100644 index 0000000..d40c010 --- /dev/null +++ b/scripts/lib/names.ts @@ -0,0 +1,14 @@ +/** Convert kebab-case or snake_case to PascalCase. */ +export function toPascalCase(name: string): string { + return name + .split(/[-_\s]+/) + .filter(Boolean) + .map((p) => p[0]!.toUpperCase() + p.slice(1)) + .join(""); +} + +/** Convert kebab-case or snake_case to camelCase. */ +export function toCamelCase(name: string): string { + const pascal = toPascalCase(name); + return pascal[0]!.toLowerCase() + pascal.slice(1); +} diff --git a/scripts/lib/openapi.ts b/scripts/lib/openapi.ts new file mode 100644 index 0000000..b6e8eaf --- /dev/null +++ b/scripts/lib/openapi.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export type OpenAPISchema = { + type?: string; + format?: string; + nullable?: boolean; + readOnly?: boolean; + writeOnly?: boolean; + enum?: unknown[]; + items?: OpenAPISchema; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | OpenAPISchema; + allOf?: OpenAPISchema[]; + oneOf?: OpenAPISchema[]; + anyOf?: OpenAPISchema[]; + $ref?: string; + description?: string; + default?: unknown; + maxLength?: number; +}; + +export type OpenAPIDoc = { + openapi: string; + components: { schemas: Record }; + paths: Record>; +}; + +export type OpenAPIOperation = { + parameters?: unknown[]; + requestBody?: { + content: Record; + }; + responses: Record< + string, + { content?: Record } + >; +}; + +const DEFAULT_OPENAPI_PATH = "../posthog/frontend/tmp/openapi.json"; + +export function loadOpenAPI(path?: string): OpenAPIDoc { + const resolved = resolve(process.cwd(), path ?? DEFAULT_OPENAPI_PATH); + const raw = readFileSync(resolved, "utf8"); + return JSON.parse(raw) as OpenAPIDoc; +} + +/** Resolve a `$ref` string like `#/components/schemas/Foo`. */ +export function resolveRef(doc: OpenAPIDoc, ref: string): OpenAPISchema { + const parts = ref.replace(/^#\//, "").split("/"); + let cur: unknown = doc; + for (const part of parts) { + if (cur && typeof cur === "object") { + cur = (cur as Record)[part]; + } + } + if (!cur) throw new Error(`Cannot resolve $ref: ${ref}`); + return cur as OpenAPISchema; +} + +/** Resolve a schema one level — if it's a $ref or a wrapping allOf around a $ref, unwrap once. */ +export function unwrap(doc: OpenAPIDoc, schema: OpenAPISchema): OpenAPISchema { + if (schema.$ref) return resolveRef(doc, schema.$ref); + if (schema.allOf && schema.allOf.length === 1 && schema.allOf[0]?.$ref) { + return resolveRef(doc, schema.allOf[0].$ref); + } + return schema; +} + +export type FieldInfo = { + name: string; + schema: OpenAPISchema; + readOnly: boolean; + writeOnly: boolean; + nullable: boolean; + required: boolean; +}; + +/** Flatten a component's properties into a list of field infos. */ +export function componentFields( + doc: OpenAPIDoc, + componentName: string, +): FieldInfo[] { + const component = doc.components.schemas[componentName]; + if (!component) { + throw new Error(`OpenAPI component not found: ${componentName}`); + } + const props = component.properties ?? {}; + const required = new Set(component.required ?? []); + return Object.entries(props).map(([name, schema]) => ({ + name, + schema, + readOnly: schema.readOnly === true, + writeOnly: schema.writeOnly === true, + nullable: schema.nullable === true, + required: required.has(name), + })); +} + +/** Find the response/request component names for a list endpoint path. */ +export function findEndpointComponents( + doc: OpenAPIDoc, + listPath: string, +): { + responseComponent?: string; + paginatedComponent?: string; + createRequestComponent?: string; +} { + const ops = doc.paths[listPath]; + if (!ops) return {}; + const getResp = ops.get?.responses?.["200"]?.content?.["application/json"]?.schema; + const postReq = ops.post?.requestBody?.content?.["application/json"]?.schema; + + let paginatedComponent: string | undefined; + let responseComponent: string | undefined; + if (getResp?.$ref) { + paginatedComponent = refTail(getResp.$ref); + const paginated = resolveRef(doc, getResp.$ref); + const items = paginated.properties?.results?.items; + if (items?.$ref) responseComponent = refTail(items.$ref); + } + + let createRequestComponent: string | undefined; + if (postReq?.$ref) createRequestComponent = refTail(postReq.$ref); + + return { responseComponent, paginatedComponent, createRequestComponent }; +} + +function refTail(ref: string): string { + const parts = ref.split("/"); + return parts[parts.length - 1] ?? ref; +} diff --git a/scripts/lib/registry.ts b/scripts/lib/registry.ts new file mode 100644 index 0000000..5ae249c --- /dev/null +++ b/scripts/lib/registry.ts @@ -0,0 +1,37 @@ +/** + * Maps each shipped resource to its OpenAPI source so `check-resources.ts` + * can compare our `ServerXSchema` against the current PostHog API surface. + * + * When you ship a new resource, add a row here. + */ +export type ResourceRegistryEntry = { + /** Matches the directory name under `src/resources/`. */ + name: string; + /** OpenAPI list-endpoint path (used to derive request/response components). */ + openapiPath: string; + /** OpenAPI component name for the GET response body (single item). */ + responseComponent: string; +}; + +export const REGISTRY: ResourceRegistryEntry[] = [ + { + name: "insight", + openapiPath: "/api/environments/{environment_id}/insights/", + responseComponent: "Insight", + }, + { + name: "dashboard", + openapiPath: "/api/environments/{environment_id}/dashboards/", + responseComponent: "Dashboard", + }, + { + name: "feature-flag", + openapiPath: "/api/projects/{project_id}/feature_flags/", + responseComponent: "FeatureFlag", + }, + { + name: "endpoint", + openapiPath: "/api/environments/{environment_id}/endpoints/", + responseComponent: "EndpointResponse", + }, +]; diff --git a/scripts/scaffold-resource.ts b/scripts/scaffold-resource.ts new file mode 100644 index 0000000..44ffee4 --- /dev/null +++ b/scripts/scaffold-resource.ts @@ -0,0 +1,721 @@ +#!/usr/bin/env tsx +/** + * Scaffold a new posthog-definitions resource from the PostHog OpenAPI spec. + * + * pnpm scaffold-resource \ + * --name cohort \ + * --path /api/projects/{project_id}/cohorts/ \ + * --key-field name + * + * Generates 5 files under `src/resources//` and wires the resource + * into `src/resources/index.ts`, `src/index.ts`, `docs/resources.md`, and + * `scripts/lib/registry.ts`. The generated `client.ts` is near-complete; the + * `pipeline.ts` and `pipeline.test.ts` are templates with `TODO(human)` + * markers covering the per-resource judgment calls (hash projection, validation, + * test fixtures). The intent is: scaffold, then hand-edit the pipeline. + */ +import { mkdirSync, existsSync, writeFileSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + componentFields, + findEndpointComponents, + loadOpenAPI, + type FieldInfo, +} from "./lib/openapi.js"; +import { toCamelCase, toPascalCase } from "./lib/names.js"; +import { buildInlinedSchema } from "./lib/extract-zod.js"; + +type Args = { + name: string; + path: string; + keyField: string; + openapiPath?: string; +}; + +function parseArgs(argv: string[]): Args { + const out: Partial = { keyField: "name" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]!; + const next = argv[i + 1]; + if (a === "--name" && next) { out.name = next; i++; } + else if (a === "--path" && next) { out.path = next; i++; } + else if (a === "--key-field" && next) { out.keyField = next; i++; } + else if (a === "--openapi" && next) { out.openapiPath = next; i++; } + else if (a === "--help" || a === "-h") { + printHelp(); + process.exit(0); + } else { + console.error(`Unknown arg: ${a}`); + printHelp(); + process.exit(1); + } + } + if (!out.name || !out.path || !out.keyField) { + printHelp(); + process.exit(1); + } + return out as Args; +} + +function printHelp(): void { + console.error( + `Usage: pnpm scaffold-resource --name --path [--key-field ] [--openapi ]\n\n` + + ` --name resource singular, kebab-case (e.g. cohort, feature-flag)\n` + + ` --path OpenAPI list endpoint path (e.g. /api/projects/{project_id}/cohorts/)\n` + + ` --key-field field on the API response that identifies the resource (default: name)\n` + + ` --openapi path to openapi.json (default: ../posthog/frontend/tmp/openapi.json)\n`, + ); +} + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = join(dirname(__filename), ".."); + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const doc = loadOpenAPI(args.openapiPath); + + const components = findEndpointComponents(doc, args.path); + if (!components.responseComponent) { + console.error( + `Could not find a response component for ${args.path}. ` + + `Make sure the path matches an entry in openapi.json/paths and that GET returns a PaginatedList.`, + ); + process.exit(1); + } + + const ctx = buildContext(args, components.responseComponent, doc); + + ensureDir(ctx.dir); + writeIfNew(join(ctx.dir, "client.ts"), renderClient(ctx, args)); + writeIfNew(join(ctx.dir, "sdk.ts"), renderSdk(ctx)); + writeIfNew(join(ctx.dir, "pipeline.ts"), renderPipeline(ctx)); + writeIfNew(join(ctx.dir, "pipeline.test.ts"), renderPipelineTest(ctx)); + writeIfNew(join(ctx.dir, "index.ts"), renderIndex(ctx)); + + appendToResourcesIndex(ctx); + appendToSdkIndex(ctx); + appendToRegistry(ctx); + flipDocsTable(ctx); + + console.log(`\nScaffolded ${ctx.singular} at ${ctx.dir}`); + console.log(`\nNext steps:`); + console.log(` 1. Open src/resources/${ctx.dir.split("/").pop()}/pipeline.ts and fill the TODO(human) markers.`); + console.log(` 2. Run pnpm typecheck and pnpm test.`); + console.log(` 3. Run pnpm pull and pnpm apply against a project that has ${ctx.plural}.`); +} + +type Ctx = { + dirName: string; // "feature-flag" + singular: string; // "featureFlag" + pascal: string; // "FeatureFlag" + plural: string; // "feature-flags" (used in iac: prefix and registry) + pluralPath: string; // "feature_flags" (URL segment) + containerKey: string; // "projects" or "environments" + containerParam: string; // "project_id" or "environment_id" (informational) + basePath: string; // /api/projects/{project_id}/feature_flags/ + responseComponent: string; + keyField: string; + fields: FieldInfo[]; // raw response component fields + hasTags: boolean; + dir: string; +}; + +function buildContext(args: Args, responseComponent: string, doc: ReturnType): Ctx { + const dirName = args.name; + const singular = toCamelCase(args.name); + const pascal = toPascalCase(args.name); + + // Derive plural URL segment from the path (e.g. .../feature_flags/ → "feature_flags"). + const segs = args.path.split("/").filter(Boolean); + const pluralPath = segs[segs.length - 1] ?? args.name; + const plural = pluralizeKebab(args.name); + + const containerKey = args.path.includes("/environments/") ? "environments" : "projects"; + const containerParam = containerKey === "environments" ? "environment_id" : "project_id"; + + const fields = componentFields(doc, responseComponent); + const hasTags = fields.some((f) => f.name === "tags"); + + return { + dirName, + singular, + pascal, + plural, + pluralPath, + containerKey, + containerParam, + basePath: args.path, + responseComponent, + keyField: args.keyField, + fields, + hasTags, + dir: join(REPO_ROOT, "src", "resources", dirName), + }; +} + +function pluralizeKebab(name: string): string { + if (name.endsWith("s")) return name; + if (name.endsWith("y")) return `${name.slice(0, -1)}ies`; + return `${name}s`; +} + +// -- File renderers -------------------------------------------------------- + +function renderClient(ctx: Ctx, args: Args): string { + // Generate the deep ServerXSchema (with all transitive component refs inlined) + // by extracting the openapi-zod-client output for this component. + const { source: schemaSource } = buildInlinedSchema( + args.openapiPath ?? "../posthog/frontend/tmp/openapi.json", + ctx.responseComponent, + `Server${ctx.pascal}Schema`, + ); + + const pluralCap = toPascalCase(ctx.plural); + const idType = idTypeFromFields(ctx.fields); + + // Create-input fields: skip readOnly (server-set). + const createFields = ctx.fields.filter((f) => !f.readOnly); + const createLines = createFields.map((f) => { + const opt = f.required ? "" : "?"; + const tsType = openapiToTsType(f); + return ` ${f.name}${opt}: ${tsType};`; + }); + + return `import { z } from "zod"; +import type { ClientConfig } from "../../client/config.js"; +import { request } from "../../client/http.js"; + +// Schemas below are generated from the OpenAPI spec via scripts/scaffold-resource.ts. +// Regenerate with: pnpm scaffold-resource (will overwrite local edits — keep them in pipeline.ts). +${schemaSource} + +const Paginated${ctx.pascal}Schema = z.object({ + count: z.number().optional(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(Server${ctx.pascal}Schema), +}); + +export type ${ctx.pascal}Create = { +${createLines.join("\n")} +}; + +export type ${ctx.pascal}Update = Partial<${ctx.pascal}Create>; + +function ${ctx.singular}sPath(projectId: string, suffix = ""): string { + return \`/api/${ctx.containerKey}/\${projectId}/${ctx.pluralPath}/\${suffix}\`; +} + +const MANAGED_TAG_PREFIX = "iac:${ctx.plural}:"; + +export async function listManaged${pluralCap}( + config: ClientConfig, + options: { verbose?: boolean } = {}, +): Promise { + const collected: Server${ctx.pascal}[] = []; + let nextPath: string | null = \`\${${ctx.singular}sPath(config.projectId)}?limit=100\`; + while (nextPath) { + const raw: unknown = await request(config, nextPath, { + verbose: options.verbose, + }); + const page = Paginated${ctx.pascal}Schema.parse(raw); + for (const row of page.results) { +${ctx.hasTags + ? ` if (row.tags?.some((tag) => typeof tag === "string" && tag.startsWith(MANAGED_TAG_PREFIX))) {\n collected.push(row);\n }` + : ` // TODO(human): no \`tags\` field on this resource — fall back to a description-marker filter\n // (see src/resources/endpoint/client.ts) or pick another identity mechanism.\n collected.push(row);`} + } + if (page.next) { + const url = new URL(page.next); + nextPath = \`\${url.pathname}\${url.search}\`; + } else { + nextPath = null; + } + } + return collected; +} + +export async function get${ctx.pascal}( + config: ClientConfig, + id: ${idType}, + options: { verbose?: boolean } = {}, +): Promise { + const raw: unknown = await request( + config, + ${ctx.singular}sPath(config.projectId, \`\${id}/\`), + { verbose: options.verbose }, + ); + return Server${ctx.pascal}Schema.parse(raw); +} + +export async function create${ctx.pascal}( + config: ClientConfig, + payload: ${ctx.pascal}Create, + options: { verbose?: boolean } = {}, +): Promise { + const raw: unknown = await request(config, ${ctx.singular}sPath(config.projectId), { + method: "POST", + body: payload, + verbose: options.verbose, + }); + return Server${ctx.pascal}Schema.parse(raw); +} + +export async function update${ctx.pascal}( + config: ClientConfig, + id: ${idType}, + payload: ${ctx.pascal}Update, + options: { verbose?: boolean } = {}, +): Promise { + const raw: unknown = await request( + config, + ${ctx.singular}sPath(config.projectId, \`\${id}/\`), + { + method: "PATCH", + body: payload, + verbose: options.verbose, + }, + ); + return Server${ctx.pascal}Schema.parse(raw); +} + +export async function delete${ctx.pascal}( + config: ClientConfig, + id: ${idType}, + options: { verbose?: boolean } = {}, +): Promise { + await request(config, ${ctx.singular}sPath(config.projectId, \`\${id}/\`), { + method: "DELETE", + verbose: options.verbose, + }); +} +`; +} + +function renderSdk(ctx: Ctx): string { + // Build a permissive user-facing input type from create fields (readOnly excluded). + // This is a starting point — humans typically narrow it. + const createFields = ctx.fields.filter((f) => !f.readOnly); + const lines = createFields.map((f) => { + const opt = f.required ? "" : "?"; + return ` ${f.name}${opt}: ${openapiToTsType(f)};`; + }); + + return `import { markResourceKind } from "../types.js"; + +/** + * TODO(human): narrow this type. The scaffold starts from the OpenAPI create + * request body, which often includes more fields than the IaC layer should + * expose (e.g. fields the server overwrites, deprecated aliases, …). + */ +export type ${ctx.pascal} = { +${lines.join("\n")} +}; + +export function ${ctx.singular}(spec: ${ctx.pascal}): ${ctx.pascal} { + return markResourceKind(spec, "${ctx.singular}"); +} +`; +} + +function renderPipeline(ctx: Ctx): string { + const tagPrefix = `iac:${ctx.plural}:`; + const pluralCap = toPascalCase(ctx.plural); + const idType = idTypeFromFields(ctx.fields); + + return `import type { ClientConfig } from "../../client/config.js"; +import { ApiError } from "../../client/http.js"; +import { specHash } from "../../apply/hash.js"; +import { + arr, + displayJson, + filterUserTags, + obj, + scalar, + type DisplayValue, +} from "../../apply/display.js"; +import { SafetyViolationError } from "../../apply/safety.js"; +import type { ApplyContext, ResourceOp } from "../types.js"; +import { getResourceKind } from "../types.js"; +import type { ${ctx.pascal} } from "./sdk.js"; +import { + create${ctx.pascal}, + delete${ctx.pascal}, + type ${ctx.pascal}Create, + get${ctx.pascal}, + type Server${ctx.pascal}, + update${ctx.pascal}, +} from "./client.js"; + +export const ${constName(ctx.singular)}_TAG_PREFIX = "${tagPrefix}"; +export const HASH_TAG_PREFIX = "iac:hash:"; + +export function ${ctx.singular}Tag(key: string): string { + return \`\${${constName(ctx.singular)}_TAG_PREFIX}\${key}\`; +} + +${ctx.hasTags ? `export function ${ctx.singular}KeyFromTags(tags: unknown[] | undefined): string | undefined { + const tag = tags?.find( + (t): t is string => typeof t === "string" && t.startsWith(${constName(ctx.singular)}_TAG_PREFIX), + ); + return tag?.slice(${constName(ctx.singular)}_TAG_PREFIX.length); +} + +export function ${ctx.singular}HashFromTags(tags: unknown[] | undefined): string | undefined { + return tags + ?.find((t): t is string => typeof t === "string" && t.startsWith(HASH_TAG_PREFIX)) + ?.slice(HASH_TAG_PREFIX.length); +} + +function hashTag(hex: string): string { + return \`\${HASH_TAG_PREFIX}\${hex}\`; +} + +function mergeTags(userTags: unknown[] | undefined, managedTags: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const tag of managedTags) { + if (seen.has(tag)) continue; + seen.add(tag); + out.push(tag); + } + for (const tag of userTags ?? []) { + if (typeof tag !== "string") continue; + if (tag.startsWith("iac:")) continue; + if (seen.has(tag)) continue; + seen.add(tag); + out.push(tag); + } + return out; +} +` : `// TODO(human): this resource does not have a \`tags\` array. Choose an +// identity mechanism — e.g. a description-marker like src/resources/endpoint +// uses — and implement \`From\`. +export function ${ctx.singular}KeyFromTags(_tags: unknown): string | undefined { + throw new Error("not implemented — pick an identity mechanism"); +} +export function ${ctx.singular}HashFromTags(_tags: unknown): string | undefined { + throw new Error("not implemented — pick an identity mechanism"); +} +`} + +/** + * TODO(human): confirm this projection includes every user-intent field and + * excludes every server-set field (id, created_at, created_by, version, iac:* tags). + * Getting this wrong means every apply rewrites unchanged resources, or skips + * real changes. See src/resources/feature-flag/pipeline.ts for a worked example. + */ +function ${ctx.singular}SpecForHash(spec: ${ctx.pascal}): unknown { + return spec; +} + +export function ${ctx.singular}Hash(spec: ${ctx.pascal}): string { + return specHash(${ctx.singular}SpecForHash(spec)); +} + +export function ${ctx.singular}Payload(spec: ${ctx.pascal}, hash: string): ${ctx.pascal}Create { + // TODO(human): construct the create/update payload. If this resource is + // tag-identified, merge \`${ctx.singular}Tag(spec.${ctx.keyField})\` and \`hashTag(hash)\` into spec.tags. + ${ctx.hasTags ? `return { ...spec, tags: mergeTags((spec as { tags?: unknown[] }).tags, [${ctx.singular}Tag(spec.${ctx.keyField} as string), hashTag(hash)]) } as ${ctx.pascal}Create;` : `void hash;\n return spec as ${ctx.pascal}Create;`} +} + +export function looksLike${ctx.pascal}(value: unknown): value is ${ctx.pascal} { + if (getResourceKind(value) === "${ctx.singular}") return true; + // TODO(human): optional structural fallback for inline specs not produced by the SDK factory. + return false; +} + +export function validate${pluralCap}(specs: ${ctx.pascal}[]): string[] { + const issues: string[] = []; + const seen = new Set(); + for (const spec of specs) { + const key = (spec as Record).${ctx.keyField}; + if (typeof key !== "string" || !key) { + issues.push("${ctx.singular}.${ctx.keyField} is required"); + continue; + } + if (seen.has(key)) issues.push(\`Duplicate ${ctx.singular} ${ctx.keyField} "\${key}"\`); + seen.add(key); + // TODO(human): add resource-specific validation rules. + } + return issues; +} + +${ctx.hasTags ? `async function assertManaged${ctx.pascal}( + config: ClientConfig, + id: ${idType}, + key: string, + options: { verbose?: boolean }, +): Promise { + const current = await get${ctx.pascal}(config, id, options); + const tags = (current as { tags?: unknown[] }).tags; + const ok = tags?.some((t) => t === ${ctx.singular}Tag(key)); + if (!ok) throw new SafetyViolationError("${ctx.singular}", id, key); +} +` : `async function assertManaged${ctx.pascal}( + _config: ClientConfig, + _id: ${idType}, + _key: string, + _options: { verbose?: boolean }, +): Promise { + // TODO(human): refetch and assert this id still bears our managed marker + // before any mutating call. Throw SafetyViolationError otherwise. +} +`} +export async function run${ctx.pascal}Op( + config: ClientConfig, + op: ResourceOp<${ctx.pascal}, Server${ctx.pascal}>, + _ctx: ApplyContext, + options: { verbose?: boolean } = {}, +): Promise { + if (op.kind === "unchanged") return; + const payload = ${ctx.singular}Payload(op.spec, op.hash); + if (op.kind === "create") { + await create${ctx.pascal}(config, payload, options); + return; + } + await assertManaged${ctx.pascal}(config, op.serverId as ${idType}, op.key, options); + await update${ctx.pascal}(config, op.serverId as ${idType}, payload, options); +} + +export async function prune${ctx.pascal}( + config: ClientConfig, + orphan: Server${ctx.pascal}, + options: { verbose?: boolean } = {}, +): Promise { + const id = (orphan as { id?: ${idType} }).id; + if (id === undefined) return false; + ${ctx.hasTags ? `const key = ${ctx.singular}KeyFromTags((orphan as { tags?: unknown[] }).tags) ?? \`id:\${id}\`;` : `const key = "TODO";`} + try { + await assertManaged${ctx.pascal}(config, id, key, options); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return false; + throw err; + } + await delete${ctx.pascal}(config, id, options); + return true; +} + +export function display${ctx.pascal}(spec: ${ctx.pascal}): DisplayValue { + // TODO(human): list user-intent fields explicitly (scalar/obj/arr) for readable diffs. + return displayJson(spec as unknown); +} + +export function display${ctx.pascal}FromServer(server: Server${ctx.pascal}): DisplayValue { + // TODO(human): mirror the displaySpec field order. \`filterUserTags\` strips iac:* tags. + void arr; void obj; void scalar; void filterUserTags; + return displayJson(server as unknown); +} +`; +} + +function renderPipelineTest(ctx: Ctx): string { + return `import { describe, it } from "node:test"; +import assert from "node:assert"; +import { ${ctx.singular}Hash, looksLike${ctx.pascal}, validate${toPascalCase(ctx.plural)} } from "./pipeline.js"; +import { ${ctx.singular} } from "./sdk.js"; + +describe("${ctx.singular} pipeline (TODO: replace fixtures)", () => { + it("validates a minimal spec", () => { + const spec = ${ctx.singular}({ ${ctx.keyField}: "demo" } as Parameters[0]); + const issues = validate${toPascalCase(ctx.plural)}([spec]); + assert.deepEqual(issues, []); + }); + + it("computes a stable hash", () => { + const spec = ${ctx.singular}({ ${ctx.keyField}: "demo" } as Parameters[0]); + const h1 = ${ctx.singular}Hash(spec); + const h2 = ${ctx.singular}Hash(spec); + assert.equal(h1, h2); + assert.match(h1, /^[a-f0-9]{16}$/); + }); + + it("recognizes its own specs", () => { + const spec = ${ctx.singular}({ ${ctx.keyField}: "demo" } as Parameters[0]); + assert.equal(looksLike${ctx.pascal}(spec), true); + assert.equal(looksLike${ctx.pascal}({ ${ctx.keyField}: "demo" }), false); + }); + + // TODO(human): add tests for: + // - create / update / unchanged / orphan ops via runFooOp + // - safety invariant (refuse to mutate a row whose managed marker has been removed) +}); +`; +} + +function renderIndex(ctx: Ctx): string { + const pluralCap = toPascalCase(ctx.plural); + return `import type { ApplyContext, ResourceModule } from "../types.js"; +import type { ${ctx.pascal} } from "./sdk.js"; +import { + display${ctx.pascal}, + display${ctx.pascal}FromServer, + ${constName(ctx.singular)}_TAG_PREFIX, + ${ctx.singular}Hash, + ${ctx.singular}HashFromTags, + ${ctx.singular}KeyFromTags, + looksLike${ctx.pascal}, + prune${ctx.pascal}, + run${ctx.pascal}Op, + validate${pluralCap}, +} from "./pipeline.js"; +import { listManaged${pluralCap}, type Server${ctx.pascal} } from "./client.js"; + +export { ${ctx.singular} } from "./sdk.js"; +export type { ${ctx.pascal} } from "./sdk.js"; + +export const ${ctx.singular}Resource: ResourceModule<${ctx.pascal}, Server${ctx.pascal}> = { + name: "${ctx.plural}", + displayName: "${ctx.singular}", + identityPrefix: ${constName(ctx.singular)}_TAG_PREFIX, + + isSpec: looksLike${ctx.pascal}, + specKey: (spec) => (spec as Record).${ctx.keyField} as string, + + list: listManaged${pluralCap}, + keyFromServer: (server) => ${ctx.singular}KeyFromTags((server as { tags?: unknown[] }).tags), + hashFromServer: (server) => ${ctx.singular}HashFromTags((server as { tags?: unknown[] }).tags), + + hash: ${ctx.singular}Hash, + validate: (specs) => validate${pluralCap}(specs), + executeOp: run${ctx.pascal}Op, + prune: prune${ctx.pascal}, + + displaySpec: (spec, _ctx: ApplyContext) => display${ctx.pascal}(spec), + displayServer: (server, _ctx: ApplyContext) => display${ctx.pascal}FromServer(server), +}; +`; +} + +// -- Index updates --------------------------------------------------------- + +function appendToResourcesIndex(ctx: Ctx): void { + const file = join(REPO_ROOT, "src", "resources", "index.ts"); + let src = readFileSync(file, "utf8"); + const importLine = `import { ${ctx.singular}Resource } from "./${ctx.dirName}/index.js";`; + const reexportLine = `export { ${ctx.singular}Resource } from "./${ctx.dirName}/index.js";`; + if (src.includes(importLine)) { + console.log(`Skipping src/resources/index.ts (already wired).`); + return; + } + // Insert import at end of import block (last `import ... from "./...index.js"`). + src = src.replace( + /(import \{ insightResource \} from "\.\/insight\/index\.js";)/, + `${importLine}\n$1`, + ); + // Add to RESOURCES array — append before closing bracket. + src = src.replace( + /(\];\n\nexport \{ insightResource \})/, + ` ${ctx.singular}Resource as ResourceModule,\n$1`, + ); + // Append re-export. + if (!src.includes(reexportLine)) { + src = src.replace( + /(export type \{ ResourceModule.*?\n)/, + `${reexportLine}\n$1`, + ); + } + writeFileSync(file, src); + console.log(`Wired src/resources/index.ts`); +} + +function appendToSdkIndex(ctx: Ctx): void { + const file = join(REPO_ROOT, "src", "index.ts"); + let src = readFileSync(file, "utf8"); + const block = `\nexport { ${ctx.singular} } from "./resources/${ctx.dirName}/index.js";\nexport type { ${ctx.pascal} } from "./resources/${ctx.dirName}/index.js";\n`; + if (src.includes(`./resources/${ctx.dirName}/index.js`)) { + console.log(`Skipping src/index.ts (already wired).`); + return; + } + writeFileSync(file, src + block); + console.log(`Wired src/index.ts`); +} + +function appendToRegistry(ctx: Ctx): void { + const file = join(REPO_ROOT, "scripts", "lib", "registry.ts"); + let src = readFileSync(file, "utf8"); + if (src.includes(`name: "${ctx.dirName}"`)) { + console.log(`Skipping scripts/lib/registry.ts (already wired).`); + return; + } + const entry = ` { + name: "${ctx.dirName}", + openapiPath: "${ctx.basePath}", + responseComponent: "${ctx.responseComponent}", + }, +];`; + src = src.replace(/\n\];\s*$/, `\n${entry}\n`); + writeFileSync(file, src); + console.log(`Wired scripts/lib/registry.ts`); +} + +function flipDocsTable(ctx: Ctx): void { + const file = join(REPO_ROOT, "docs", "resources.md"); + if (!existsSync(file)) return; + let src = readFileSync(file, "utf8"); + // Heuristic: find a row that mentions the path segment. + const pathHint = ctx.pluralPath; + const rowRe = new RegExp(`(\\| [^|]+ \\| ✅ \`[^\`]*${escapeRegex(pathHint)}[^\`]*\` \\| )❌( \\|[^\\n]*)`); + if (!rowRe.test(src)) { + console.log(`Could not find a docs/resources.md row to flip for ${pathHint}.`); + return; + } + src = src.replace(rowRe, `$1✅$2`); + writeFileSync(file, src); + console.log(`Flipped docs/resources.md row for ${pathHint}.`); +} + +// -- helpers --------------------------------------------------------------- + +function idTypeFromFields(fields: FieldInfo[]): string { + const id = fields.find((f) => f.name === "id"); + if (!id) return "number | string"; + if (id.schema.type === "integer" || id.schema.type === "number") return "number"; + return "string"; +} + +function openapiToTsType(f: FieldInfo): string { + const t = f.schema.type; + let base: string; + if (t === "string") { + if (f.schema.enum && Array.isArray(f.schema.enum) && f.schema.enum.every((v) => typeof v === "string")) { + base = (f.schema.enum as string[]).map((v) => JSON.stringify(v)).join(" | "); + } else base = "string"; + } else if (t === "integer" || t === "number") base = "number"; + else if (t === "boolean") base = "boolean"; + else if (t === "array") base = "unknown[]"; + else if (t === "object") base = "Record"; + else base = "unknown"; + return f.nullable ? `${base} | null` : base; +} + +function ensureDir(dir: string): void { + mkdirSync(dir, { recursive: true }); +} + +function writeIfNew(path: string, content: string): void { + if (existsSync(path)) { + console.log(`Skipping ${shortPath(path)} (already exists).`); + return; + } + writeFileSync(path, content); + console.log(`Wrote ${shortPath(path)}`); +} + +function shortPath(p: string): string { + return p.replace(REPO_ROOT + "/", ""); +} + +function constName(camel: string): string { + return camel.replace(/([A-Z])/g, "_$1").toUpperCase(); +} + +function safeKey(name: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +main();