From d9bdf17771c3ead44491e30082d745ab53fe2a11 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 20 May 2026 15:00:38 +0200 Subject: [PATCH] fix: repair resend env and schema scaffolds Resolve optional Resend env reads through createEnv runtime proxy opt-in. Align Resend table literals with camelCase extension keys. Harden env helper reruns and codex-review helper. --- .agents/skills/codex-review/SKILL.md | 139 ++++++++ .../skills/codex-review/scripts/codex-review | 333 ++++++++++++++++++ .changeset/fix-resend-env-schema.md | 10 + bun.lock | 4 +- ...fold-env-proxy-and-table-names-20260520.md | 136 +++++++ packages/kitcn/src/cli/cli.commands.ts | 16 + .../cli/registry/items/resend/resend-item.ts | 3 + .../items/resend/resend-schema.template.ts | 8 +- .../kitcn/src/cli/registry/planner.test.ts | 195 ++++++++++ packages/kitcn/src/cli/registry/planner.ts | 219 +++++++++++- packages/kitcn/src/cli/registry/types.ts | 1 + packages/kitcn/src/server/env.test.ts | 104 +++++- packages/kitcn/src/server/env.ts | 56 ++- skills-lock.json | 6 + 14 files changed, 1213 insertions(+), 17 deletions(-) create mode 100644 .agents/skills/codex-review/SKILL.md create mode 100755 .agents/skills/codex-review/scripts/codex-review create mode 100644 .changeset/fix-resend-env-schema.md create mode 100644 docs/solutions/integration-issues/resend-scaffold-env-proxy-and-table-names-20260520.md diff --git a/.agents/skills/codex-review/SKILL.md b/.agents/skills/codex-review/SKILL.md new file mode 100644 index 00000000..3ba0db14 --- /dev/null +++ b/.agents/skills/codex-review/SKILL.md @@ -0,0 +1,139 @@ +--- +name: codex-review +description: "Codex autoreview/code review closeout: local dirty changes, PR branch vs main, parallel tests." +--- + +# Codex Review + +Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing. + +Use when: +- user asks for Codex review / autoreview / second-model review +- after non-trivial code edits, before final/commit/ship +- reviewing a local branch or PR branch after fixes + +## Contract + +- Treat review output as advisory. Never blindly apply it. +- Verify every finding by reading the real code path and adjacent files. +- Read dependency docs/source/types when the finding depends on external behavior. +- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase. +- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class. +- Keep going until Codex review returns no accepted/actionable findings. +- If a review-triggered fix changes code, rerun focused tests and rerun Codex review. +- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk. +- Never switch or override the review model. If the review hits model capacity, retry the same command a few times with the same model. The helper runs nested review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior. +- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording. +- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse. +- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know. +- If `gh`/Gitcrawl reports `database disk image is malformed`, run `gitcrawl doctor --json` once to let the portable cache repair before retrying review; do not bypass the shim unless repair fails and freshness requires live GitHub. +- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub. +- Do not push just to review. Push only when the user requested push/ship/PR update. + +## Pick Target + +Dirty local work: + +```bash +codex review --uncommitted +``` + +Use this only when the patch is actually unstaged/staged/untracked in the +current checkout. For committed, pushed, or PR work, point Codex at the commit +or branch diff instead; do not force `--mode local` / `--uncommitted` just +because the helper docs mention dirty work first. A clean `--uncommitted` review +only proves there is no local patch. + +Branch/PR work: + +```bash +git fetch origin +codex review --base origin/main +``` + +Do not pass any prompt with `--base`. Some Codex CLI versions reject both inline +and stdin prompt forms, including helper commands shaped like +`codex review --base -`, with `--base cannot be used with +[PROMPT]`. If the helper hits this error, run plain `codex review --base ` +and report that helper prompt injection was skipped. + +If an open PR exists, use its actual base: + +```bash +base=$(gh pr view --json baseRefName --jq .baseRefName) +codex review --base "origin/$base" +``` + +Committed single change: + +```bash +codex review --commit HEAD +``` + +or with the helper: + +```bash +./.agents/skills/codex-review/scripts/codex-review --mode commit --commit HEAD +``` + +Use commit review for already-landed or already-pushed work on `main`. Reviewing +clean `main` against `origin/main` is usually an empty diff after push. For a +small stack, review each commit explicitly or review the branch before merging +with `--base`. + +## Parallel Closeout + +Format first if formatting can change line locations. Then it is OK to run tests and review in parallel: + +```bash +scripts/codex-review --parallel-tests "" +``` + +Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation. + +## Context Efficiency + +Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only: +- actionable findings it accepts +- findings it rejects, with one-line reason +- exact files/tests to rerun + +Run inline only for tiny changes or when subagents are unavailable. + +## Helper + +Bundled helper: + +```bash +~/.codex/skills/codex-review/scripts/codex-review --help +``` + +If installed from `agent-scripts`, path is: + +```bash +./.agents/skills/codex-review/scripts/codex-review --help +``` + +The helper: +- chooses dirty `--uncommitted` first +- otherwise uses current PR base if `gh pr view` works +- otherwise uses `origin/main` for non-main branches +- auto-runs `PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check` in parallel when a repo has `package.json`, `pnpm-lock.yaml`, `node_modules`, and a `check` script; disable with `CODEX_REVIEW_AUTO_TESTS=0` +- use `--mode commit --commit ` for already-committed work, especially clean `main` after landing +- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing +- writes only to stdout unless `--output` or `CODEX_REVIEW_OUTPUT` is set +- supports `--dry-run`, `--parallel-tests`, and commit refs +- runs nested review with `--dangerously-bypass-approvals-and-sandbox` by default +- branch mode may fail on Codex CLI versions that reject `--base` plus the helper's stdin prompt; on that exact parser error, rerun plain `codex review --base ` instead of falling back to a non-Codex reviewer +- keeps accepting `--full-access`; use `--no-yolo` or `CODEX_REVIEW_YOLO=0` to opt out +- prints `codex-review clean: no accepted/actionable findings reported` when the selected review command exits 0 + +## Final Report + +Include: +- review command used +- tests/proof run +- findings accepted/rejected, briefly why +- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected + +Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean. diff --git a/.agents/skills/codex-review/scripts/codex-review b/.agents/skills/codex-review/scripts/codex-review new file mode 100755 index 00000000..084995f8 --- /dev/null +++ b/.agents/skills/codex-review/scripts/codex-review @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: codex-review [options] + +Options: + --mode auto|local|branch|commit + Target selection. Default: auto. + --base REF Base ref for branch review. Default: PR base or origin/main. + --commit REF Commit ref for commit review. Default: HEAD. + --codex-bin PATH Codex binary. Default: codex. + --full-access Keep yolo/full-access mode enabled. Default. + --no-yolo Run nested Codex review with normal sandbox/approval prompts. + --output FILE Also save output to file. + --parallel-tests CMD Run review and test command concurrently. + Default: PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check when available. + --dry-run Print selected commands, do not run. + -h, --help Show help. + +Modes: + local codex review --uncommitted + branch codex review --base + commit codex review --commit + auto dirty tree -> local, else PR/current branch -> branch +EOF +} + +mode=auto +base_ref= +commit_ref=HEAD +codex_bin=${CODEX_BIN:-codex} +codex_args=() +yolo=${CODEX_REVIEW_YOLO:-1} +output=${CODEX_REVIEW_OUTPUT:-} +parallel_tests= +parallel_tests_auto=false +dry_run=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) + mode=${2:-} + shift 2 + ;; + --base) + base_ref=${2:-} + shift 2 + ;; + --commit) + commit_ref=${2:-} + shift 2 + ;; + --codex-bin) + codex_bin=${2:-} + shift 2 + ;; + --full-access) + yolo=1 + shift + ;; + --no-yolo) + yolo=0 + shift + ;; + --output) + output=${2:-} + shift 2 + ;; + --parallel-tests) + parallel_tests=${2:-} + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac +done + +case "$yolo" in + 0|false|False|FALSE|no|No|NO|off|Off|OFF) ;; + *) codex_args+=(--dangerously-bypass-approvals-and-sandbox) ;; +esac + +case "$mode" in + auto|local|branch|commit) ;; + *) + echo "invalid --mode: $mode" >&2 + exit 2 + ;; +esac + +repo_root=$(git rev-parse --show-toplevel) +printf -v quoted_repo_root '%q' "$repo_root" + +has_package_check_script() { + command -v node >/dev/null 2>&1 || return 1 + node -e 'const { readFileSync } = require("node:fs"); const p = JSON.parse(readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.check ? 0 : 1)' \ + "$repo_root/package.json" \ + >/dev/null 2>&1 +} + +auto_tests_disabled() { + case "${CODEX_REVIEW_AUTO_TESTS:-1}" in + 0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;; + *) return 1 ;; + esac +} + +if [[ -z "$parallel_tests" ]] && ! auto_tests_disabled; then + if [[ -f "$repo_root/package.json" && -f "$repo_root/pnpm-lock.yaml" && -d "$repo_root/node_modules" ]] && + command -v pnpm >/dev/null 2>&1 && + has_package_check_script; then + parallel_tests="cd $quoted_repo_root && PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check" + parallel_tests_auto=true + fi +fi + +current_branch=$(git branch --show-current 2>/dev/null || true) +dirty=false +if [[ -n "$(git status --porcelain)" ]]; then + dirty=true +fi + +pr_url= +if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then + if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then + base_name=${pr_lines%%$'\t'*} + pr_url=${pr_lines#*$'\t'} + if [[ -n "$base_name" ]]; then + base_ref="origin/$base_name" + fi + fi +fi + +if [[ -z "$base_ref" ]]; then + base_ref=origin/main +fi + +review_kind= +if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then + review_kind=local +elif [[ "$mode" == commit ]]; then + review_kind=commit +elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then + review_kind=branch +else + echo "no review target: clean main checkout and no forced mode" >&2 + exit 1 +fi + +if [[ "$review_kind" == local ]]; then + review_cmd=("$codex_bin" "${codex_args[@]}" review --uncommitted) +elif [[ "$review_kind" == commit ]]; then + review_cmd=("$codex_bin" "${codex_args[@]}" review --commit "$commit_ref") +else + review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref") +fi + +printf 'codex-review target: %s\n' "$review_kind" +printf 'branch: %s\n' "${current_branch:-detached}" +if [[ -n "$pr_url" ]]; then + printf 'pr: %s\n' "$pr_url" +fi +printf 'review:' +printf ' %q' "${review_cmd[@]}" +printf '\n' +if [[ -n "$parallel_tests" ]]; then + printf 'tests: %s' "$parallel_tests" + if [[ "$parallel_tests_auto" == true ]]; then + printf ' (auto)' + fi + printf '\n' +fi +if [[ "$review_kind" == branch ]]; then + printf 'fetch: git fetch origin --quiet\n' +fi +if [[ -n "$output" ]]; then + printf 'output: %s\n' "$output" +fi + +if [[ "$dry_run" == true ]]; then + exit 0 +fi + +if [[ "$review_kind" == branch ]]; then + git fetch origin --quiet || { + echo "warning: git fetch origin failed; reviewing with existing refs" >&2 + } +fi + +review_output=$output +review_output_is_temp=false +if [[ -z "$review_output" ]]; then + review_output=$(mktemp) + review_output_is_temp=true +fi + +cleanup() { + if [[ "${review_output_is_temp:-false}" == true && -n "${review_output:-}" ]]; then + rm -f "$review_output" + fi +} +trap cleanup EXIT + +run_review() { + mkdir -p "$(dirname "$review_output")" + "${review_cmd[@]}" 2>&1 | tee "$review_output" +} + +elapsed_since() { + local started_at=$1 + local finished_at + finished_at=$(date +%s) + printf '%s\n' "$((finished_at - started_at))" +} + +format_elapsed() { + local seconds=$1 + if (( seconds < 60 )); then + printf '%ss\n' "$seconds" + else + printf '%sm%ss\n' "$((seconds / 60))" "$((seconds % 60))" + fi +} + +review_output_empty() { + [[ ! -s "$review_output" ]] || ! grep -q '[^[:space:]]' "$review_output" +} + +review_output_has_findings() { + local final_review_output + final_review_output=$( + awk ' + /^codex$/ { + seen = 1 + output = "" + next + } + seen { + output = output $0 "\n" + } + END { + printf "%s", output + } + ' "$review_output" + ) + if [[ -z "$final_review_output" ]]; then + final_review_output=$(cat "$review_output") + fi + grep -Eq '^[[:space:]]*-[[:space:]]+\[P[0-3]\]' <<<"$final_review_output" +} + +report_clean_review_or_fail() { + local elapsed_text + elapsed_text=$(format_elapsed "${review_elapsed_seconds:-0}") + + if review_output_has_findings; then + printf 'codex-review complete after %s\n' "$elapsed_text" + printf 'codex-review findings: accepted/actionable findings reported\n' + return 1 + fi + if review_output_empty; then + printf 'codex-review complete after %s; no output\n' "$elapsed_text" + return 1 + fi + printf 'codex-review complete after %s\n' "$elapsed_text" + printf 'codex-review clean: no accepted/actionable findings reported\n' +} + +if [[ -z "$parallel_tests" ]]; then + review_started_at=$(date +%s) + set +e + run_review + review_status=$? + review_elapsed_seconds=$(elapsed_since "$review_started_at") + set -e + if [[ "$review_status" == 0 ]]; then + report_clean_review_or_fail + exit $? + fi + exit "$review_status" +fi + +review_status_file=$(mktemp) +review_elapsed_file=$(mktemp) +tests_status_file=$(mktemp) + +( + set +e + review_started_at=$(date +%s) + run_review + status=$? + elapsed=$(elapsed_since "$review_started_at") + printf '%s\n' "$status" > "$review_status_file" + printf '%s\n' "$elapsed" > "$review_elapsed_file" +) & +review_pid=$! + +( + set +e + bash -lc "$parallel_tests" + status=$? + printf '%s\n' "$status" > "$tests_status_file" +) & +tests_pid=$! + +wait "$review_pid" || true +wait "$tests_pid" || true + +review_status=$(cat "$review_status_file") +review_elapsed_seconds=$(cat "$review_elapsed_file") +tests_status=$(cat "$tests_status_file") +rm -f "$review_status_file" "$review_elapsed_file" "$tests_status_file" + +printf 'codex-review exit: %s\n' "$review_status" +printf 'tests exit: %s\n' "$tests_status" + +if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then + exit 1 +fi + +report_clean_review_or_fail diff --git a/.changeset/fix-resend-env-schema.md b/.changeset/fix-resend-env-schema.md new file mode 100644 index 00000000..c10c16e6 --- /dev/null +++ b/.changeset/fix-resend-env-schema.md @@ -0,0 +1,10 @@ +--- +"kitcn": patch +--- + +## Patches + +- Fix Resend scaffolds to resolve optional Resend env values from Convex runtime env proxies. +- Fix Resend env helper reruns to update noncanonical `createEnv` formatting instead of silently skipping `readOptionalRuntimeEnv`. +- Fix env helper reruns to fail loudly instead of duplicating or rewriting non-literal `readOptionalRuntimeEnv` options. +- Fix Resend scaffold table names to match the camelCase schema extension keys. diff --git a/bun.lock b/bun.lock index 7be51bdd..cf7470b9 100644 --- a/bun.lock +++ b/bun.lock @@ -119,7 +119,7 @@ }, "packages/kitcn": { "name": "kitcn", - "version": "0.14.3", + "version": "0.15.1", "bin": { "kitcn": "./dist/cli.mjs", "intent": "./bin/intent.js", @@ -177,7 +177,7 @@ }, "packages/resend": { "name": "@kitcn/resend", - "version": "0.14.3", + "version": "0.15.1", "dependencies": { "svix": "^1.84.1", }, diff --git a/docs/solutions/integration-issues/resend-scaffold-env-proxy-and-table-names-20260520.md b/docs/solutions/integration-issues/resend-scaffold-env-proxy-and-table-names-20260520.md new file mode 100644 index 00000000..16135752 --- /dev/null +++ b/docs/solutions/integration-issues/resend-scaffold-env-proxy-and-table-names-20260520.md @@ -0,0 +1,136 @@ +--- +title: Resend scaffold optional env reads must be lazy and table names must match schema keys +date: 2026-05-20 +last_updated: 2026-05-20 +category: integration-issues +module: kitcn resend scaffold +problem_type: integration_issue +component: tooling +symptoms: + - "`kitcn add resend` apps send `Bearer undefined` or an invalid Resend API key even when `RESEND_API_KEY` is configured." + - "Convex dashboard shows Resend table-name drift between camelCase schema keys and snake_case ORM writes." + - "A direct optional env read fix makes Convex codegen fail with `Environment variable RESEND_API_KEY is used in auth config file but its value was not set`." +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: [resend, env, getenv, auth-config, schema-extension, scenarios] +--- + +# Resend scaffold optional env reads must be lazy and table names must match schema keys + +## Problem + +Resend looked configured, but queued emails never reached Resend because the +scaffolded plugin could resolve `RESEND_API_KEY` as missing. The same scaffold +also used snake_case `convexTable(...)` names while registering camelCase schema +extension keys, so generated Convex schema tables and ORM table targets could +drift apart. + +## Symptoms + +- Resend returns `{"statusCode":401,"name":"validation_error","message":"API key is invalid"}`. +- Direct `process.env.RESEND_API_KEY` reads work, but `getEnv().RESEND_API_KEY` + can still be `undefined`. +- Changing only the Resend table literals to camelCase makes the table drift go + away, but does not fix email sending. +- A naive generated-helper fix that direct-reads every optional schema key makes + `convex-next-all` fail during `kitcn codegen`: + +```text +Environment variable RESEND_API_KEY is used in auth config file but its value was not set. +``` + +## What Didn't Work + +- Reading optional Resend keys directly while building the whole env object. + That fixes hidden Convex env proxies for Resend, but it also makes + `auth.config.ts` import `getEnv()` and touch `RESEND_API_KEY` during Convex's + auth config scanner. +- Changing only the Resend table names. That fixes the schema/ORM table target + mismatch, but leaves the API key path broken. +- Making Resend plugin scaffolds use `process.env` directly. That violates the + plugin scaffold contract and spreads env ownership out of `getEnv()`. + +## Solution + +Add an explicit `createEnv({ readOptionalRuntimeEnv })` opt-in, but make those +reads lazy. The env helper can advertise which optional keys may need direct +runtime proxy access without reading those keys while unrelated callers ask for +other env values. + +```ts +export const getEnv = createEnv({ + readOptionalRuntimeEnv: [ + "RESEND_API_KEY", + "RESEND_WEBHOOK_SECRET", + "RESEND_FROM_EMAIL", + ], + schema: envSchema, +}); +``` + +`createEnv()` should only read a marked optional key immediately if normal +presence checks prove the key exists. Otherwise define a getter on the parsed env +result, so `getEnv().JWKS` does not read Resend keys, but +`getEnv().RESEND_API_KEY` still resolves through Convex's runtime env proxy. + +Also keep extension keys and physical table names aligned: + +```ts +export const resendEmailsTable = convexTable("resendEmails", { + // ... +}); + +export function resendExtension() { + return defineSchemaExtension("resend", { + resendEmails: resendEmailsTable, + }); +} +``` + +## Why This Works + +Convex can expose runtime env values through an object where direct property +access works but `hasOwn`, property descriptors, or `in` checks do not prove the +optional key exists. `createEnv()` avoids blind optional reads because Convex's +auth config scanner treats `process.env.X` access as an env requirement. + +Lazy getters split those concerns: + +1. auth config reads only the auth env value it needs +2. Resend reads Resend env only inside Resend plugin paths +3. hidden runtime env proxies still work when the optional key is actually used + +The table fix works because `defineSchemaExtension()` injects tables by object +key, while ORM writes use the table object's `tableName`. Those names must agree +for generated schema, Convex data model, and ORM calls to hit the same table. + +## Prevention + +- When fixing optional env reads, verify the combined auth + plugin scenario, + not just the isolated package tests. +- Add tests for hidden env proxies where direct property access works but + `hasOwn`, descriptors, and `in` checks all fail. +- Add rerun tests for valid but noncanonical scaffold formatting. A string + inserter that only matches freshly generated code can silently leave existing + apps broken. +- Make source scanners comment-aware. Braces inside comments should not end the + `createEnv({ ... })` object before existing options are inspected. +- Fail loudly when an existing helper uses a non-literal + `readOptionalRuntimeEnv` value. Duplicating the option lets JavaScript keep + the later property, and partially rewriting spread/asserted arrays can discard + existing plugin keys. +- Include defaulted optional keys in proxy-read tests. Otherwise Zod defaults can + hide a real runtime value behind Convex's env proxy. +- Add a test that `getEnv().JWKS` does not read `RESEND_API_KEY` when both auth + and Resend env fields share the same helper. +- For plugin schema extensions, assert `convexTable("")` literals match + the extension keys that register those tables. +- Run `bun run scenario:check -- convex-next-all` for auth/ratelimit/resend + scaffold changes. + +## Related Issues + +- [Combined plugin scenario strict function types](/Users/zbeyens/git/better-convex/docs/solutions/integration-issues/combined-plugin-scenario-strict-function-types-20260317.md) +- [Concave codegen must load root .env for parse-time module imports](/Users/zbeyens/git/better-convex/docs/solutions/integration-issues/concave-codegen-must-load-root-env-for-parse-time-modules-20260408.md) +- [Published @kitcn/resend packages must self-build before pack](/Users/zbeyens/git/better-convex/docs/solutions/integration-issues/published-resend-package-must-self-build-before-pack-20260401.md) diff --git a/packages/kitcn/src/cli/cli.commands.ts b/packages/kitcn/src/cli/cli.commands.ts index 9a9cbef9..1d5983e8 100644 --- a/packages/kitcn/src/cli/cli.commands.ts +++ b/packages/kitcn/src/cli/cli.commands.ts @@ -4025,6 +4025,10 @@ describe('cli/cli', () => { 'RESEND_WEBHOOK_SECRET: z.string().optional()' ); expect(envSource).toContain('RESEND_FROM_EMAIL: z.string().optional()'); + expect(envSource).toContain('readOptionalRuntimeEnv: ['); + expect(envSource).toContain("'RESEND_API_KEY'"); + expect(envSource).toContain("'RESEND_WEBHOOK_SECRET'"); + expect(envSource).toContain("'RESEND_FROM_EMAIL'"); const createdConfig = JSON.parse( fs.readFileSync(path.join(dir, 'kitcn.json'), 'utf8') ) as { @@ -4171,6 +4175,18 @@ describe('cli/cli', () => { expect(resendWebhookSource).not.toContain('initCRPC.create('); expect(resendWebhookSource).not.toContain('const c ='); expect(resendSchemaSource).toContain('export function resendExtension()'); + expect(resendSchemaSource).toContain( + 'export const resendContentTable = convexTable("resendContent", {' + ); + expect(resendSchemaSource).toContain( + 'export const resendNextBatchRunTable = convexTable("resendNextBatchRun", {' + ); + expect(resendSchemaSource).toContain(' "resendDeliveryEvents",'); + expect(resendSchemaSource).toContain(' "resendEmails",'); + expect(resendSchemaSource).not.toContain('"resend_content"'); + expect(resendSchemaSource).not.toContain('"resend_next_batch_run"'); + expect(resendSchemaSource).not.toContain('"resend_delivery_events"'); + expect(resendSchemaSource).not.toContain('"resend_emails"'); expect(resendSchemaSource).toContain('defineSchemaExtension("resend", {'); expect(resendSchemaSource).toContain('}).relations((r) => ({'); expect(resendSchemaSource).toContain('deliveryEvents: r.many'); diff --git a/packages/kitcn/src/cli/registry/items/resend/resend-item.ts b/packages/kitcn/src/cli/registry/items/resend/resend-item.ts index ff2f73ca..fdc76aa6 100644 --- a/packages/kitcn/src/cli/registry/items/resend/resend-item.ts +++ b/packages/kitcn/src/cli/registry/items/resend/resend-item.ts @@ -134,6 +134,7 @@ export const resendRegistryItem = defineInternalRegistryItem({ envFields: [ { key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, schema: 'z.string().optional()', reminder: { message: 'Set before sending email through Resend.', @@ -141,10 +142,12 @@ export const resendRegistryItem = defineInternalRegistryItem({ }, { key: 'RESEND_WEBHOOK_SECRET', + readOptionalRuntimeEnv: true, schema: 'z.string().optional()', }, { key: 'RESEND_FROM_EMAIL', + readOptionalRuntimeEnv: true, schema: 'z.string().optional()', }, ], diff --git a/packages/kitcn/src/cli/registry/items/resend/resend-schema.template.ts b/packages/kitcn/src/cli/registry/items/resend/resend-schema.template.ts index 584ca8ee..5fbdb15c 100644 --- a/packages/kitcn/src/cli/registry/items/resend/resend-schema.template.ts +++ b/packages/kitcn/src/cli/registry/items/resend/resend-schema.template.ts @@ -12,19 +12,19 @@ export const RESEND_SCHEMA_TEMPLATE = `import { unionOf, } from "kitcn/orm"; -export const resendContentTable = convexTable("resend_content", { +export const resendContentTable = convexTable("resendContent", { content: bytes().notNull(), mimeType: text().notNull(), filename: text(), path: text(), }); -export const resendNextBatchRunTable = convexTable("resend_next_batch_run", { +export const resendNextBatchRunTable = convexTable("resendNextBatchRun", { runId: text().notNull(), }); export const resendDeliveryEventsTable = convexTable( - "resend_delivery_events", + "resendDeliveryEvents", { emailId: text().notNull(), resendId: text().notNull(), @@ -39,7 +39,7 @@ export const resendDeliveryEventsTable = convexTable( ); export const resendEmailsTable = convexTable( - "resend_emails", + "resendEmails", { from: text().notNull(), to: arrayOf(text().notNull()).notNull(), diff --git a/packages/kitcn/src/cli/registry/planner.test.ts b/packages/kitcn/src/cli/registry/planner.test.ts index fa9a67b4..15ec0d52 100644 --- a/packages/kitcn/src/cli/registry/planner.test.ts +++ b/packages/kitcn/src/cli/registry/planner.test.ts @@ -15,6 +15,201 @@ describe('cli registry planner', () => { ); }); + test('renders direct optional runtime env reads for marked fields', () => { + const source = renderEnvHelperContent([ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ]); + + expect(source).toContain('readOptionalRuntimeEnv: ['); + expect(source).toContain("'RESEND_API_KEY'"); + }); + + test('updates existing env helpers with direct optional runtime env reads', () => { + const source = renderEnvHelperContent( + [ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ], + `import { createEnv } from 'kitcn/server'; +import { z } from 'zod'; + +const envSchema = z.object({ + RESEND_API_KEY: z.string().optional(), +}); + +export const getEnv = createEnv({ + schema: envSchema, +}); +` + ); + + expect(source).toContain('readOptionalRuntimeEnv: ['); + expect(source).toContain("'RESEND_API_KEY'"); + }); + + test('keeps generated env helpers stable when direct optional runtime env reads already exist', () => { + const fields = [ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ]; + const source = renderEnvHelperContent(fields); + + expect(renderEnvHelperContent(fields, source)).toBe(source); + }); + + test('updates existing env helpers when comments contain braces', () => { + const source = renderEnvHelperContent( + [ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ], + `import { createEnv } from 'kitcn/server'; +import { z } from 'zod'; + +const envSchema = z.object({ + JWKS: z.string().optional(), + RESEND_API_KEY: z.string().optional(), +}); + +export const getEnv = createEnv({ + // generated object closes with } + readOptionalRuntimeEnv: [ + 'JWKS', + ], + schema: envSchema, +}); +` + ); + + expect(source.match(/readOptionalRuntimeEnv/g)).toHaveLength(1); + expect(source).toContain("'JWKS'"); + expect(source).toContain("'RESEND_API_KEY'"); + }); + + test('updates inline env helpers with direct optional runtime env reads', () => { + const source = renderEnvHelperContent( + [ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ], + `import { createEnv } from 'kitcn/server'; +import { z } from 'zod'; + +const envSchema = z.object({ + RESEND_API_KEY: z.string().optional(), +}); + +export const getEnv = createEnv({ schema: envSchema }); +` + ); + + expect(source).toContain('readOptionalRuntimeEnv: ['); + expect(source).toContain("'RESEND_API_KEY'"); + expect(source).toContain(' schema: envSchema,\n});'); + }); + + test('throws before duplicating non-literal direct optional runtime env reads', () => { + expect(() => + renderEnvHelperContent( + [ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ], + `import { createEnv } from 'kitcn/server'; +import { z } from 'zod'; + +const optionalKeys = ['JWKS']; +const envSchema = z.object({ + DEPLOY_ENV: z.string().default('production'), + SITE_URL: z.string().default('http://localhost:3000'), + RESEND_API_KEY: z.string().optional(), +}); + +export const getEnv = createEnv({ + readOptionalRuntimeEnv: optionalKeys, + schema: envSchema, +}); +` + ) + ).toThrow('inline array'); + }); + + test('throws before rewriting spread direct optional runtime env arrays', () => { + expect(() => + renderEnvHelperContent( + [ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ], + `import { createEnv } from 'kitcn/server'; +import { z } from 'zod'; + +const optionalKeys = ['JWKS']; +const envSchema = z.object({ + DEPLOY_ENV: z.string().default('production'), + SITE_URL: z.string().default('http://localhost:3000'), + RESEND_API_KEY: z.string().optional(), +}); + +export const getEnv = createEnv({ + readOptionalRuntimeEnv: [...optionalKeys], + schema: envSchema, +}); +` + ) + ).toThrow('string literals'); + }); + + test('throws before corrupting asserted direct optional runtime env arrays', () => { + expect(() => + renderEnvHelperContent( + [ + { + key: 'RESEND_API_KEY', + readOptionalRuntimeEnv: true, + schema: 'z.string().optional()', + }, + ], + `import { createEnv } from 'kitcn/server'; +import { z } from 'zod'; + +const envSchema = z.object({ + DEPLOY_ENV: z.string().default('production'), + SITE_URL: z.string().default('http://localhost:3000'), + RESEND_API_KEY: z.string().optional(), +}); + +export const getEnv = createEnv({ + readOptionalRuntimeEnv: ['JWKS'] as const, + schema: envSchema, +}); +` + ) + ).toThrow('string literals'); + }); + test('reconciles scaffold files before turning them into plan files', async () => { const dir = fs.mkdtempSync( path.join(os.tmpdir(), 'kitcn-registry-reconcile-') diff --git a/packages/kitcn/src/cli/registry/planner.ts b/packages/kitcn/src/cli/registry/planner.ts index 2300a0e4..ea720fee 100644 --- a/packages/kitcn/src/cli/registry/planner.ts +++ b/packages/kitcn/src/cli/registry/planner.ts @@ -73,8 +73,88 @@ const BASE_ENV_FIELDS: readonly PluginEnvField[] = [ }, ] as const; const ENV_SCHEMA_RE = /(const\s+\w+\s*=\s*z\.object\(\{\n)([\s\S]*?)(\n\}\);)/m; +const CREATE_ENV_OPTIONS_START_RE = /createEnv\s*\(\s*\{/m; +const READ_OPTIONAL_RUNTIME_ENV_PROPERTY_RE = /\breadOptionalRuntimeEnv\s*:/m; +const READ_OPTIONAL_RUNTIME_ENV_RE = + /(\s*readOptionalRuntimeEnv\s*:\s*\[)([\s\S]*?)(\]\s*,?)/m; +const LEADING_WHITESPACE_RE = /^\s*/; +const STRING_LITERAL_ARRAY_ENTRY_RE = /^(['"])([^'"]+)\1$/; const WHITESPACE_RE = /\s/; +const findMatchingObjectBraceIndex = (source: string, openIndex: number) => { + let depth = 0; + let quote: '"' | "'" | '`' | undefined; + let escaped = false; + let lineComment = false; + let blockComment = false; + + for (let index = openIndex; index < source.length; index++) { + const char = source[index]; + const nextChar = source[index + 1]; + + if (lineComment) { + if (char === '\n' || char === '\r') { + lineComment = false; + } + continue; + } + + if (blockComment) { + if (char === '*' && nextChar === '/') { + blockComment = false; + index++; + } + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === quote) { + quote = undefined; + } + continue; + } + + if (char === '/' && nextChar === '/') { + lineComment = true; + index++; + continue; + } + + if (char === '/' && nextChar === '*') { + blockComment = true; + index++; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + + if (char === '{') { + depth++; + continue; + } + + if (char === '}') { + depth--; + if (depth === 0) { + return index; + } + } + } + + return -1; +}; + export const resolveEnvBootstrapPlanFileDetails = (templateId: string) => { if (templateId === KITCN_CONFIG_TEMPLATE_ID) { return { @@ -143,17 +223,143 @@ const resolveBootstrapEnvFields = (envFields: readonly PluginEnvField[]) => { return fields; }; +const resolveReadOptionalRuntimeEnvKeys = (fields: readonly PluginEnvField[]) => + fields + .filter((field) => field.readOptionalRuntimeEnv) + .map((field) => field.key); + +const renderReadOptionalRuntimeEnvOption = (keys: readonly string[]) => { + if (keys.length === 0) { + return ''; + } + const keyLines = keys.map((key) => ` '${key}',`).join('\n'); + return ` readOptionalRuntimeEnv: [\n${keyLines}\n ],\n`; +}; + +const parseReadOptionalRuntimeEnvKeys = ( + existingOptionsBody: string, + existingMatch: RegExpMatchArray +) => { + if (existingMatch.index === undefined) { + return []; + } + + const matchEnd = existingMatch.index + existingMatch[0].length; + const hasPropertySeparator = existingMatch[3].includes(','); + if (!hasPropertySeparator && existingOptionsBody.slice(matchEnd).trim()) { + throw new Error( + 'Expected env helper `readOptionalRuntimeEnv` to be an inline array of string literals before adding keys.' + ); + } + + const rawEntries = existingMatch[2].trim(); + if (!rawEntries) { + return []; + } + + const keys: string[] = []; + const entries = rawEntries.split(','); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index].trim(); + if (!entry && index === entries.length - 1) { + continue; + } + + const stringLiteralMatch = entry.match(STRING_LITERAL_ARRAY_ENTRY_RE); + if (!stringLiteralMatch) { + throw new Error( + 'Expected env helper `readOptionalRuntimeEnv` to be an inline array of string literals before adding keys.' + ); + } + keys.push(stringLiteralMatch[2]); + } + + return keys; +}; + +const upsertReadOptionalRuntimeEnvOption = ( + source: string, + keys: readonly string[] +) => { + if (keys.length === 0) { + return source; + } + + const createEnvMatch = source.match(CREATE_ENV_OPTIONS_START_RE); + if (!createEnvMatch || createEnvMatch.index === undefined) { + throw new Error( + 'Expected env helper to call `createEnv({ ... })` before adding `readOptionalRuntimeEnv`.' + ); + } + + const insertIndex = + createEnvMatch.index + createEnvMatch[0].lastIndexOf('{') + 1; + const openBraceIndex = insertIndex - 1; + const closingBraceIndex = findMatchingObjectBraceIndex( + source, + openBraceIndex + ); + + if (closingBraceIndex === -1) { + throw new Error( + 'Expected env helper `createEnv` options object to close before adding `readOptionalRuntimeEnv`.' + ); + } + + const existingOptionsBody = source.slice(insertIndex, closingBraceIndex); + const existingMatch = existingOptionsBody.match(READ_OPTIONAL_RUNTIME_ENV_RE); + if ( + !existingMatch && + READ_OPTIONAL_RUNTIME_ENV_PROPERTY_RE.test(existingOptionsBody) + ) { + throw new Error( + 'Expected env helper `readOptionalRuntimeEnv` to be an inline array before adding keys.' + ); + } + + const existingKeys = existingMatch + ? parseReadOptionalRuntimeEnvKeys(existingOptionsBody, existingMatch) + : []; + const nextKeys = [...new Set([...existingKeys, ...keys])]; + const option = renderReadOptionalRuntimeEnvOption(nextKeys); + + if (existingMatch && existingMatch.index !== undefined) { + const leadingWhitespace = + existingMatch[1].match(LEADING_WHITESPACE_RE)?.[0] ?? ''; + const replacement = `${leadingWhitespace}${option.trimStart().trimEnd()}`; + const existingOptionStart = insertIndex + existingMatch.index; + return `${source.slice(0, existingOptionStart)}${replacement}${source.slice(existingOptionStart + existingMatch[0].length)}`; + } + + const before = source.slice(0, insertIndex); + const after = source.slice(insertIndex); + + if (after.startsWith('\n')) { + return `${before}\n${option}${after.slice(1)}`; + } + + const body = source.slice(insertIndex, closingBraceIndex).trim(); + const bodyLine = + body.length === 0 ? '' : ` ${body}${body.endsWith(',') ? '' : ','}\n`; + + return `${before}\n${option}${bodyLine}${source.slice(closingBraceIndex)}`; +}; + export const renderEnvHelperContent = ( envFields: readonly PluginEnvField[], existingContent?: string ): string => { const fields = resolveBootstrapEnvFields(envFields); + const readOptionalRuntimeEnvKeys = resolveReadOptionalRuntimeEnvKeys(fields); if (!existingContent) { const fieldLines = fields .map((field) => ` ${field.key}: ${field.schema},`) .join('\n'); - return `import { createEnv } from 'kitcn/server';\nimport { z } from 'zod';\n\nconst envSchema = z.object({\n${fieldLines}\n});\n\nexport const getEnv = createEnv({\n schema: envSchema,\n});\n`; + const readOptionalRuntimeEnvOption = renderReadOptionalRuntimeEnvOption( + readOptionalRuntimeEnvKeys + ); + return `import { createEnv } from 'kitcn/server';\nimport { z } from 'zod';\n\nconst envSchema = z.object({\n${fieldLines}\n});\n\nexport const getEnv = createEnv({\n${readOptionalRuntimeEnvOption} schema: envSchema,\n});\n`; } const match = existingContent.match(ENV_SCHEMA_RE); @@ -172,13 +378,16 @@ export const renderEnvHelperContent = ( .map((field) => ` ${field.key}: ${field.schema},`); if (missingFieldLines.length === 0) { - return existingContent; + return upsertReadOptionalRuntimeEnvOption( + existingContent, + readOptionalRuntimeEnvKeys + ); } const nextBody = `${existingBody}${existingBody.endsWith('\n') ? '' : '\n'}${missingFieldLines.join('\n')}`; - return existingContent.replace( - ENV_SCHEMA_RE, - `${match[1]}${nextBody}${match[3]}` + return upsertReadOptionalRuntimeEnvOption( + existingContent.replace(ENV_SCHEMA_RE, `${match[1]}${nextBody}${match[3]}`), + readOptionalRuntimeEnvKeys ); }; diff --git a/packages/kitcn/src/cli/registry/types.ts b/packages/kitcn/src/cli/registry/types.ts index 2e220e1f..46443f94 100644 --- a/packages/kitcn/src/cli/registry/types.ts +++ b/packages/kitcn/src/cli/registry/types.ts @@ -40,6 +40,7 @@ export type PluginEnvField = { value: string; }; key: string; + readOptionalRuntimeEnv?: boolean; schema: string; reminder?: { message?: string; diff --git a/packages/kitcn/src/server/env.test.ts b/packages/kitcn/src/server/env.test.ts index 1568d3c8..333152fa 100644 --- a/packages/kitcn/src/server/env.test.ts +++ b/packages/kitcn/src/server/env.test.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { createEnv } from './env'; +import { createEnv, type RuntimeEnv } from './env'; import { CRPCError } from './error'; describe('server/env', () => { @@ -371,4 +371,106 @@ describe('server/env', () => { process.env = originalEnv; } }); + + test('reads configured optional keys through direct runtimeEnv access', () => { + const schema = z.object({ + RESEND_API_KEY: z.string().optional(), + }); + const runtimeEnv = new Proxy({} as RuntimeEnv, { + get(_target, property) { + if (property === 'RESEND_API_KEY') { + return 're_secret'; + } + }, + getOwnPropertyDescriptor() { + return undefined; + }, + has() { + return false; + }, + }) as RuntimeEnv; + + const getEnv = createEnv({ + cache: false, + readOptionalRuntimeEnv: ['RESEND_API_KEY'], + runtimeEnv, + schema, + }); + + expect(getEnv()).toEqual({ + RESEND_API_KEY: 're_secret', + }); + }); + + test('reads configured defaulted optional keys through direct runtimeEnv access', () => { + const schema = z.object({ + DEPLOY_ENV: z.string().default('production'), + }); + const runtimeEnv = new Proxy({} as RuntimeEnv, { + get(_target, property) { + if (property === 'DEPLOY_ENV') { + return 'development'; + } + }, + getOwnPropertyDescriptor() { + return undefined; + }, + has() { + return false; + }, + }) as RuntimeEnv; + + const getEnv = createEnv({ + cache: false, + readOptionalRuntimeEnv: ['DEPLOY_ENV'], + runtimeEnv, + schema, + }); + + expect(getEnv()).toEqual({ + DEPLOY_ENV: 'development', + }); + }); + + test('defers direct optional runtimeEnv reads until the key is accessed', () => { + const schema = z.object({ + JWKS: z.string().optional(), + RESEND_API_KEY: z.string().optional(), + }); + let resendApiKeyReads = 0; + const runtimeEnv = new Proxy({ JWKS: 'jwks-json' } as RuntimeEnv, { + get(target, property, receiver) { + if (property === 'RESEND_API_KEY') { + resendApiKeyReads += 1; + return 're_secret'; + } + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + if (property === 'RESEND_API_KEY') { + return undefined; + } + return Reflect.getOwnPropertyDescriptor(target, property); + }, + has(target, property) { + if (property === 'RESEND_API_KEY') { + return false; + } + return Reflect.has(target, property); + }, + }) as RuntimeEnv; + + const getEnv = createEnv({ + cache: false, + readOptionalRuntimeEnv: ['RESEND_API_KEY'], + runtimeEnv, + schema, + }); + + const env = getEnv(); + expect(env.JWKS).toBe('jwks-json'); + expect(resendApiKeyReads).toBe(0); + expect(env.RESEND_API_KEY).toBe('re_secret'); + expect(resendApiKeyReads).toBe(1); + }); }); diff --git a/packages/kitcn/src/server/env.ts b/packages/kitcn/src/server/env.ts index fe24d07e..eb7372e0 100644 --- a/packages/kitcn/src/server/env.ts +++ b/packages/kitcn/src/server/env.ts @@ -1,11 +1,12 @@ import { z } from 'zod'; import { CRPCError } from './error'; -type RuntimeEnv = Record; +export type RuntimeEnv = Record; export type CreateEnvOptions> = { cache?: boolean; codegenFallback?: boolean; + readOptionalRuntimeEnv?: readonly string[]; runtimeEnv?: RuntimeEnv; schema: TSchema; }; @@ -13,9 +14,22 @@ export type CreateEnvOptions> = { export function createEnv>( options: CreateEnvOptions ): () => z.infer { - const { schema, runtimeEnv, cache = true, codegenFallback = false } = options; + const { + schema, + runtimeEnv, + cache = true, + codegenFallback = false, + readOptionalRuntimeEnv = [], + } = options; + const directOptionalKeys = new Set(readOptionalRuntimeEnv); let cached: z.infer | undefined; + const createInvalidEnvError = () => + new CRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Invalid environment variables', + }); + return () => { if (cache && cached) { return cached; @@ -37,6 +51,19 @@ export function createEnv>( const undefinedParse = (zodType as z.ZodType).safeParse(undefined); const acceptsUndefined = undefinedParse.success; if (acceptsUndefined) { + if (directOptionalKeys.has(key)) { + if ( + Object.hasOwn(runtimeEnvSource, key) || + Object.getOwnPropertyDescriptor(runtimeEnvSource, key) !== + undefined || + key in runtimeEnvSource + ) { + runtimeEnvSnapshot[key] = runtimeEnvSource[key]; + } else if (!isCodegenParse && undefinedParse.data !== undefined) { + runtimeEnvSnapshot[key] = runtimeEnvSource[key]; + } + continue; + } // Avoid direct reads for missing optional keys so auth-config env // tracking does not treat absent optional vars as required. if ( @@ -90,9 +117,28 @@ export function createEnv>( const parsed = schema.safeParse(envForParse); if (!parsed.success) { - throw new CRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Invalid environment variables', + throw createInvalidEnvError(); + } + + const parsedData = parsed.data as Record; + for (const key of directOptionalKeys) { + if (!(key in schema.shape) || parsedData[key] !== undefined) { + continue; + } + Object.defineProperty(parsedData, key, { + configurable: true, + enumerable: true, + get: () => { + const value = runtimeEnvSource[key]; + if (value === undefined) { + return undefined; + } + const result = (schema.shape[key] as z.ZodType).safeParse(value); + if (!result.success) { + throw createInvalidEnvError(); + } + return result.data; + }, }); } diff --git a/skills-lock.json b/skills-lock.json index d44918d7..62dddec1 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -36,6 +36,12 @@ "sourceType": "github", "computedHash": "0786830917e459be293f89c99fa83c513fd7631bceef1f0f18b951f78ed1f957" }, + "codex-review": { + "source": "steipete/agent-scripts", + "sourceType": "github", + "skillPath": "skills/codex-review/SKILL.md", + "computedHash": "e3a1292fb7143420f81f4ca078ae8ea1b43f280de27f8b7f91f4bf0868d0f95a" + }, "coding-tutor": { "source": "EveryInc/compound-engineering-plugin", "sourceType": "github",