diff --git a/.changeset/align-missing-schema-host-hints.md b/.changeset/align-missing-schema-host-hints.md new file mode 100644 index 000000000..87f803bc3 --- /dev/null +++ b/.changeset/align-missing-schema-host-hints.md @@ -0,0 +1,9 @@ +--- +"@arkenv/vite-plugin": patch +"@arkenv/nextjs": patch +"@arkenv/nuxt": patch +--- + +#### Align missing-schema errors with short, actionable host guidance + +Point missing-schema errors at checked paths / `schemaPath` and `arkenv init`, matching the Bun plugin style, without embedding starter `env.ts` modules. diff --git a/.changeset/align-v1-shared-missing-schema-errors.md b/.changeset/align-v1-shared-missing-schema-errors.md new file mode 100644 index 000000000..6e3e2409b --- /dev/null +++ b/.changeset/align-v1-shared-missing-schema-errors.md @@ -0,0 +1,17 @@ +--- +"@arkenv/build": patch +"@arkenv/bun-plugin": patch +"@arkenv/vite-plugin": patch +"@arkenv/nextjs": patch +"@arkenv/nuxt": patch +--- + +#### Make missing-schema errors short and actionable across hosts + +When a host cannot find an env schema, throw a consistent message that names the expected path / `schemaPath` and points to `npx arkenv@latest init`, without embedding a starter `env.ts` module. + +Example: + +```text +[ArkEnv] Could not find schema file at src/env.ts or env.ts. Please specify 'schemaPath' in ArkEnv options (or run `npx arkenv@latest init`). +``` diff --git a/.changeset/optional-strict-shared-v1.md b/.changeset/optional-strict-shared-v1.md new file mode 100644 index 000000000..4eafdded5 --- /dev/null +++ b/.changeset/optional-strict-shared-v1.md @@ -0,0 +1,18 @@ +--- +"@arkenv/build": patch +"@arkenv/nextjs": patch +"@arkenv/nuxt": patch +--- + +#### Make `env/internal/shared.ts` optional in strict layout + +Strict layout now works with just `client.ts` and `server.ts`. Omit `internal/shared.ts` when you have nothing to share — shared keys are treated as empty. + +```ts +// env/client.ts + env/server.ts alone is enough +export default withArkEnv(nextConfig, { + layout: "strict", +}); +``` + +The CLI still scaffolds `shared.ts` by default for convenience. diff --git a/.changeset/pre.json b/.changeset/pre.json index f52140d3f..520e6dfec 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -27,7 +27,9 @@ "add-host-preset-add-command", "add-host-presets-phase-4", "add-host-strict-layouts-forward-port", + "align-missing-schema-host-hints", "align-missing-schema-throw", + "align-v1-shared-missing-schema-errors", "bun-env-module-transform", "drop-example-e-alias", "epg3nspi", @@ -46,9 +48,11 @@ "nuxt-skip-setup-before-boot-gate", "nuxt-strict-auto-extend", "nuxt-strict-client-shared-auto-extend", + "optional-strict-shared-v1", "reconcile-v0-features", "remove-framework-shared-exports", "rename-create-env-to-arkenv", + "split-help-global-init-options", "standard-isolation-guards", "standard-mode-flat-layout", "standard-mode-packaging", diff --git a/.changeset/split-help-global-init-options.md b/.changeset/split-help-global-init-options.md new file mode 100644 index 000000000..d8df262b5 --- /dev/null +++ b/.changeset/split-help-global-init-options.md @@ -0,0 +1,30 @@ +--- +"arkenv": patch +--- + +#### Split `--help` options into Global and `init` sections + +List shared flags under **Global options** and scaffolding flags under **init options**, matching the multi-command `/docs/cli` taxonomy. + +```bash +npx arkenv@alpha --help +``` + +```text +Usage: + arkenv init [project-name] ... + arkenv add host [provider] ... + +Global options: + --yes, -y Skip prompts and use defaults ... + --quiet, -q Quiet mode ... + --json, -j Output structured JSON ... + --agent Enable non-interactive, machine-readable mode ... + --help, -h Show this help message + +init options: + --example Specify an example name ... + --force, -f Bypass checks and force scaffolding + --no-codegen Disable automatic env.gen.ts code generation ... + --host-preset, -H Specify a hosting provider preset ... +``` diff --git a/.github/workflows/deploy-www-manual.yml b/.github/workflows/deploy-www-manual.yml new file mode 100644 index 000000000..f00b5bcc8 --- /dev/null +++ b/.github/workflows/deploy-www-manual.yml @@ -0,0 +1,111 @@ +name: Deploy www (manual SHA) + +# Maintainer escape hatch: deploy any commit to a stable www URL without +# pushing that commit to the target branch. Requires write access to run. +on: + workflow_dispatch: + inputs: + sha: + description: Full commit SHA to build and deploy + required: true + type: string + target: + description: Stable URL to update + required: true + type: choice + options: + - arkenv-dev.vercel.app + - arkenv-v1.vercel.app + - arkenv.js.org + +concurrency: + group: deploy-www-manual-${{ inputs.target }} + cancel-in-progress: true + +jobs: + Deploy-Manual: + runs-on: ubuntu-latest + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + TARGET: ${{ inputs.target }} + DEPLOY_SHA: ${{ inputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + - name: Resolve git metadata for the deployed commit + id: meta + run: | + set -euo pipefail + case "$TARGET" in + arkenv-dev.vercel.app) echo "git_ref=dev" >> "$GITHUB_OUTPUT" ;; + arkenv-v1.vercel.app) echo "git_ref=v1" >> "$GITHUB_OUTPUT" ;; + arkenv.js.org) echo "git_ref=main" >> "$GITHUB_OUTPUT" ;; + *) + echo "Unknown target: $TARGET" >&2 + exit 1 + ;; + esac + echo "short_msg=$(git log -1 --format=%s "$DEPLOY_SHA" | head -n1)" >> "$GITHUB_OUTPUT" + echo "author_name=$(git log -1 --format=%an "$DEPLOY_SHA")" >> "$GITHUB_OUTPUT" + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: pnpm + - name: Install dependencies + run: pnpm install + - name: Install Vercel CLI + # Pin the CLI to avoid upstream breakage from newly published majors. + run: npm install --global vercel@54.7.1 + - name: Pull Vercel Environment Information + run: | + if [ "$TARGET" = "arkenv.js.org" ]; then + node scripts/vercel-wrapper.cjs pull --yes --environment=production --token="$VERCEL_TOKEN" + else + node scripts/vercel-wrapper.cjs pull --yes --environment=preview --token="$VERCEL_TOKEN" + fi + - name: Build Project Artifacts + run: | + if [ "$TARGET" = "arkenv.js.org" ]; then + node scripts/vercel-wrapper.cjs build --prod --token="$VERCEL_TOKEN" + else + node scripts/vercel-wrapper.cjs build --token="$VERCEL_TOKEN" + fi + - name: Deploy and attach to stable URL + env: + GIT_ORG: ${{ github.repository_owner }} + GIT_REPO: ${{ github.event.repository.name }} + GIT_REF: ${{ steps.meta.outputs.git_ref }} + GIT_SHA: ${{ inputs.sha }} + GIT_COMMIT_MESSAGE: ${{ steps.meta.outputs.short_msg }} + GIT_COMMIT_AUTHOR_NAME: ${{ steps.meta.outputs.author_name }} + GIT_COMMIT_AUTHOR_LOGIN: ${{ github.actor }} + run: | + set -o pipefail + META_ARGS=( + -m githubDeployment=1 + -m githubOrg="$GIT_ORG" + -m githubRepo="$GIT_REPO" + -m githubCommitOrg="$GIT_ORG" + -m githubCommitRepo="$GIT_REPO" + -m githubCommitRef="$GIT_REF" + -m githubCommitSha="$GIT_SHA" + -m githubCommitMessage="$GIT_COMMIT_MESSAGE" + -m githubCommitAuthorName="$GIT_COMMIT_AUTHOR_NAME" + -m githubCommitAuthorLogin="$GIT_COMMIT_AUTHOR_LOGIN" + ) + if [ "$TARGET" = "arkenv.js.org" ]; then + node scripts/vercel-wrapper.cjs deploy --prebuilt --prod --archive=tgz --token="$VERCEL_TOKEN" "${META_ARGS[@]}" + echo "Deployed $GIT_SHA to production (arkenv.js.org)." + else + DEPLOYMENT_URL=$(node scripts/vercel-wrapper.cjs deploy --prebuilt --archive=tgz --token="$VERCEL_TOKEN" "${META_ARGS[@]}" | tail -n 1) + echo "Deployment URL: $DEPLOYMENT_URL" + echo "Aliasing $DEPLOYMENT_URL -> $TARGET" + node scripts/vercel-wrapper.cjs alias set "$DEPLOYMENT_URL" "$TARGET" --token="$VERCEL_TOKEN" + fi diff --git a/.github/workflows/deploy-www.yml b/.github/workflows/deploy-www.yml index e1183419a..22b25f3ce 100644 --- a/.github/workflows/deploy-www.yml +++ b/.github/workflows/deploy-www.yml @@ -42,4 +42,28 @@ jobs: - name: Build Project Artifacts run: node scripts/vercel-wrapper.cjs build --prod --token=${{ secrets.VERCEL_TOKEN }} - name: Deploy Project Artifacts to Vercel - run: node scripts/vercel-wrapper.cjs deploy --prebuilt --prod --archive=tgz --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + GIT_ORG: ${{ github.repository_owner }} + GIT_REPO: ${{ github.event.repository.name }} + GIT_REF: ${{ github.ref_name }} + GIT_SHA: ${{ github.sha }} + GIT_ACTOR: ${{ github.actor }} + GIT_COMMIT_MESSAGE: ${{ github.event.head_commit.message || github.sha }} + GIT_COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name || github.actor }} + run: | + set -o pipefail + SHORT_MSG="$(printf '%s\n' "$GIT_COMMIT_MESSAGE" | head -n1)" + # --prod already assigns production domains (arkenv.js.org / arkenv.vercel.app). + # Git meta keeps the deployment linked to main in the Vercel dashboard. + node scripts/vercel-wrapper.cjs deploy --prebuilt --prod --archive=tgz --token="$VERCEL_TOKEN" \ + -m githubDeployment=1 \ + -m githubOrg="$GIT_ORG" \ + -m githubRepo="$GIT_REPO" \ + -m githubCommitOrg="$GIT_ORG" \ + -m githubCommitRepo="$GIT_REPO" \ + -m githubCommitRef="$GIT_REF" \ + -m githubCommitSha="$GIT_SHA" \ + -m githubCommitMessage="$SHORT_MSG" \ + -m githubCommitAuthorName="$GIT_COMMIT_AUTHOR_NAME" \ + -m githubCommitAuthorLogin="$GIT_ACTOR" diff --git a/.github/workflows/preview-www-default.yml b/.github/workflows/preview-www-default.yml index b6cee9371..be975c16c 100644 --- a/.github/workflows/preview-www-default.yml +++ b/.github/workflows/preview-www-default.yml @@ -1,7 +1,9 @@ name: Preview www (Default Events) on: - pull_request: + # pull_request_target so fork PRs receive repository secrets (VERCEL_*). + # Workflow YAML is taken from the base branch; we checkout PR HEAD in the reusable job. + pull_request_target: types: [opened, synchronize, ready_for_review] branches: - dev @@ -19,6 +21,7 @@ jobs: Deploy-Preview: # Branch previews (push to dev/v1) always deploy. PR previews are opt-in: # they only deploy when the PR carries the `preview` label, regardless of draft state. + # Fork authors cannot apply labels (triage+ required), so this is not self-serve spamable. if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'preview') uses: ./.github/workflows/preview-www-reusable.yml secrets: inherit diff --git a/.github/workflows/preview-www-labeled.yml b/.github/workflows/preview-www-labeled.yml index 8043e4714..13ba6fd8f 100644 --- a/.github/workflows/preview-www-labeled.yml +++ b/.github/workflows/preview-www-labeled.yml @@ -1,7 +1,8 @@ name: Preview www (Label Triggered) on: - pull_request: + # pull_request_target so fork PRs receive repository secrets when a maintainer applies `preview`. + pull_request_target: types: [labeled] branches: - dev diff --git a/.github/workflows/preview-www-reusable.yml b/.github/workflows/preview-www-reusable.yml index b65dd6daf..b01c9983c 100644 --- a/.github/workflows/preview-www-reusable.yml +++ b/.github/workflows/preview-www-reusable.yml @@ -3,25 +3,33 @@ name: Preview www Reusable on: workflow_call: +# Non-secret defaults only. Vercel credentials are injected solely on Vercel CLI steps +# so untrusted PR HEAD install/build scripts do not see them ambiently. env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} TZ: ${{ vars.TIMEZONE || 'Asia/Almaty' }} jobs: Deploy-Preview: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v6 with: fetch-depth: 0 + # pull_request_target defaults to the base ref; use the base repo's pull head + # ref so fork commits resolve and base history remains available for turbo. + ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.sha }} + persist-credentials: false - name: Set Turbo base/head for PRs + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | - echo "TURBO_SCM_BASE=${{ github.event.pull_request.base.sha || github.event.before }}" >> $GITHUB_ENV - echo "TURBO_SCM_HEAD=${{ github.sha }}" >> $GITHUB_ENV + echo "TURBO_SCM_BASE=${PR_BASE_SHA}" >> "$GITHUB_ENV" + echo "TURBO_SCM_HEAD=${PR_HEAD_SHA}" >> "$GITHUB_ENV" - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -31,45 +39,117 @@ jobs: run: pnpm install - name: Check if www is affected id: affected + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | - BASE_SHA="${{ github.event.pull_request.base.sha || github.event.before }}" if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="HEAD~1" fi - echo "Comparing base ref: $BASE_SHA with head: ${{ github.sha }}" - - if pnpm exec turbo query affected --packages=www --base="$BASE_SHA" --head="${{ github.sha }}" --exit-code; then + echo "Comparing base ref: $BASE_SHA with head: $HEAD_SHA" + + # Deploy-path edits must still rebuild/alias branch domains (turbo won't mark www). + if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -qE '^(\.github/workflows/preview-www|scripts/vercel-wrapper\.cjs)'; then + echo "Preview deploy path changed; forcing www deploy." + echo "is_affected=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if pnpm exec turbo query affected --packages=www --base="$BASE_SHA" --head="$HEAD_SHA" --exit-code; then echo "www is NOT affected." - echo "is_affected=false" >> $GITHUB_OUTPUT + echo "is_affected=false" >> "$GITHUB_OUTPUT" else echo "www IS affected." - echo "is_affected=true" >> $GITHUB_OUTPUT + echo "is_affected=true" >> "$GITHUB_OUTPUT" fi - name: Install Vercel CLI # Pin the CLI to avoid upstream breakage from newly published majors. run: npm install --global vercel@54.7.1 - name: Pull Vercel Environment Information if: steps.affected.outputs.is_affected == 'true' - run: node scripts/vercel-wrapper.cjs pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: node scripts/vercel-wrapper.cjs pull --yes --environment=preview --token="$VERCEL_TOKEN" - name: Build Project Artifacts if: steps.affected.outputs.is_affected == 'true' - run: node scripts/vercel-wrapper.cjs build --token=${{ secrets.VERCEL_TOKEN }} + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + run: node scripts/vercel-wrapper.cjs build --token="$VERCEL_TOKEN" - name: Deploy Project Artifacts to Vercel if: steps.affected.outputs.is_affected == 'true' id: deploy + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + # Git meta so CLI deploys associate with the correct branch (branch domains / env). + # See https://vercel.com/kb/guide/branch-variables-and-domains-not-linked-to-cli-deployments + GIT_ORG: ${{ github.repository_owner }} + GIT_REPO: ${{ github.event.repository.name }} + GIT_REF: ${{ github.event.pull_request.head.ref || github.ref_name }} + GIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + GIT_ACTOR: ${{ github.event.pull_request.head.user.login || github.actor }} + GIT_COMMIT_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message || github.sha }} + GIT_COMMIT_AUTHOR_NAME: ${{ github.event.head_commit.author.name || github.actor }} run: | set -o pipefail - DEPLOYMENT_URL=$(node scripts/vercel-wrapper.cjs deploy --prebuilt --archive=tgz --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1) - echo "DEPLOYMENT_URL=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT + SHORT_MSG="$(printf '%s\n' "$GIT_COMMIT_MESSAGE" | head -n1)" + DEPLOYMENT_URL=$(node scripts/vercel-wrapper.cjs deploy --prebuilt --archive=tgz --token="$VERCEL_TOKEN" \ + -m githubDeployment=1 \ + -m githubOrg="$GIT_ORG" \ + -m githubRepo="$GIT_REPO" \ + -m githubCommitOrg="$GIT_ORG" \ + -m githubCommitRepo="$GIT_REPO" \ + -m githubCommitRef="$GIT_REF" \ + -m githubCommitSha="$GIT_SHA" \ + -m githubCommitMessage="$SHORT_MSG" \ + -m githubCommitAuthorName="$GIT_COMMIT_AUTHOR_NAME" \ + -m githubCommitAuthorLogin="$GIT_ACTOR" \ + | tail -n 1) + echo "DEPLOYMENT_URL=$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT" + # Explicit alias so branch domains stay current without native Vercel Git builds (#1460). + # Only on push to dev/v1 — labeled PR previews keep ephemeral URLs and must not steal these. + - name: Alias branch preview domain + if: >- + github.event_name == 'push' && + (github.ref_name == 'dev' || github.ref_name == 'v1') && + steps.affected.outputs.is_affected == 'true' + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + DEPLOYMENT_URL: ${{ steps.deploy.outputs.DEPLOYMENT_URL }} + BRANCH: ${{ github.ref_name }} + run: | + case "$BRANCH" in + dev) DOMAIN="arkenv-dev.vercel.app" ;; + v1) DOMAIN="arkenv-v1.vercel.app" ;; + *) + echo "No branch domain mapping for $BRANCH; skipping alias." + exit 0 + ;; + esac + if [ -z "$DEPLOYMENT_URL" ]; then + echo "No deployment URL; skipping alias." + exit 0 + fi + echo "Aliasing $DEPLOYMENT_URL -> $DOMAIN" + node scripts/vercel-wrapper.cjs alias set "$DEPLOYMENT_URL" "$DOMAIN" --token="$VERCEL_TOKEN" - name: Generate GitHub App Token id: generate-token uses: actions/create-github-app-token@v3 - if: env.APP_PRIVATE_KEY != '' + if: vars.APP_ID != '' with: client-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Comment PR - if: github.event_name == 'pull_request' && steps.affected.outputs.is_affected == 'true' + if: github.event_name != 'push' && steps.affected.outputs.is_affected == 'true' uses: actions/github-script@v9 with: github-token: ${{ steps.generate-token.outputs.token || secrets.GITHUB_TOKEN }} diff --git a/apps/www/content/docs/nextjs/layouts/strict.mdx b/apps/www/content/docs/nextjs/layouts/strict.mdx index 12d20d430..a4878f0a9 100644 --- a/apps/www/content/docs/nextjs/layouts/strict.mdx +++ b/apps/www/content/docs/nextjs/layouts/strict.mdx @@ -4,13 +4,13 @@ icon: ShieldCheck description: Get the best security in Next.js using strictly separated client and server schemas. --- -In this setup, you define your client, server, and shared schemas in separate files. +In this setup, you define your client and server schemas in separate files. An optional `internal/shared.ts` holds variables shared by both. ```files src └── env ├── internal - │ └── shared.ts + │ └── shared.ts # optional ├── client.ts └── server.ts ``` @@ -38,7 +38,7 @@ npx arkenv@latest init --strict ## Your schema -When bootstrapping with the CLI, it generates three separate schema files under `src/env/`: +When bootstrapping with the CLI, it generates separate schema files under `src/env/` (including an optional `internal/shared.ts` with `NODE_ENV` for convenience): :::note By default, ArkEnv for Next.js uses an automatic code generation wrapper (`withArkEnv`) in your Next.js config to compile your `runtimeEnv` block. This is why the client-side schema imports from `./generated/env.gen` rather than `@arkenv/nextjs/client`. To opt out, simply import from `@arkenv/nextjs/client` directly and provide your own `runtimeEnv` mapping. @@ -127,9 +127,9 @@ export const env = arkenv( If you're using Standard Schema without ArkType, install `@arkenv/standard` instead of `@arkenv/core` — see [Zod, Valibot, and other Standard Schema validators](/docs/nextjs/using-other-validators). ::: - ### Define shared variables [step] [!toc] + ### Define shared variables (optional) [step] [!toc] - Create `env/internal/shared.ts` to define schema variables that are shared across both the client and the server. We treat this as a runtime type, so it is defined using PascalCase: + Optionally create `env/internal/shared.ts` to define schema variables that are shared across both the client and the server. Shared is not required for strict layout—omit the file when you have nothing to share. When present, we treat it as a runtime type, so it is defined using PascalCase: ```ts title="src/env/internal/shared.ts" twoslash import { type } from "@arkenv/core"; @@ -145,7 +145,7 @@ export const env = arkenv( ### Define client variables [step] [!toc] - Create `env/client.ts` importing `@arkenv/core` from the auto-generated `./generated/env.gen.ts` helper file. All local keys **must** be prefixed with `NEXT_PUBLIC_` and you must extend `SharedSchema`: + Create `env/client.ts` importing `@arkenv/core` from the auto-generated `./generated/env.gen.ts` helper file. All local keys **must** be prefixed with `NEXT_PUBLIC_`. When you define `SharedSchema`, extend it here: ```ts title="src/env/client.ts" twoslash // @filename: /env/internal/shared.ts diff --git a/apps/www/content/docs/nuxt/configuration.mdx b/apps/www/content/docs/nuxt/configuration.mdx index f26ae8dcc..f0cdc84e0 100644 --- a/apps/www/content/docs/nuxt/configuration.mdx +++ b/apps/www/content/docs/nuxt/configuration.mdx @@ -81,10 +81,10 @@ Thrown validation errors are separate from logged diagnostics — the logger onl In strict layout, the module automatically composes: -1. `env/internal/shared.ts` → client entry (as `SharedSchema`) +1. `env/internal/shared.ts` → client entry (as `SharedSchema`), when the file exists 2. Client env → server entry -The shared file must live at `internal/shared.ts` under your schema directory and export `SharedSchema`. That relative path is not configurable. To change composition — for example to extend additional schemas, or to skip the default merge — pass an explicit `extends` option in your client or server entry. See [Extending your schema](/docs/nuxt/layouts/strict#extending-your-schema). +Shared is optional: omit `internal/shared.ts` when you have nothing to share (empty merge). When present, the file must live at `internal/shared.ts` under your schema directory and export `SharedSchema`. That relative path is not configurable. To change composition — for example to extend additional schemas, or to skip the default merge — pass an explicit `extends` option in your client or server entry. See [Extending your schema](/docs/nuxt/layouts/strict#extending-your-schema). `schemaPath` only relocates the schema directory as a whole (for example `src/env` instead of `env`). It does not rename `internal/shared.ts` inside that directory. diff --git a/apps/www/content/docs/nuxt/layouts/strict.mdx b/apps/www/content/docs/nuxt/layouts/strict.mdx index dda56a89f..233c2f9c0 100644 --- a/apps/www/content/docs/nuxt/layouts/strict.mdx +++ b/apps/www/content/docs/nuxt/layouts/strict.mdx @@ -4,12 +4,12 @@ icon: ShieldCheck description: Get the best security in Nuxt using strictly separated client and server schemas. --- -In this setup, you define your client, server, and shared schemas in separate files. +In this setup, you define your client and server schemas in separate files. An optional `internal/shared.ts` holds variables shared by both. ```files env ├── internal -│ └── shared.ts +│ └── shared.ts # optional ├── client.ts └── server.ts ``` @@ -37,7 +37,7 @@ npx arkenv@latest init --strict ## Your schema -When bootstrapping with the CLI, it generates three separate schema files under `env/`: +When bootstrapping with the CLI, it generates separate schema files under `env/` (including an optional `internal/shared.ts` with `NODE_ENV` for convenience): ```ts twoslash title="env/internal/shared.ts" import { type } from "@arkenv/core"; @@ -85,10 +85,10 @@ export const env = arkenv({ With `@arkenv/nuxt/module` registered: -- The **client** entry includes `SharedSchema` from `env/internal/shared.ts` -- The **server** entry includes the composed client env (and therefore shared variables) +- The **client** entry includes `SharedSchema` from `env/internal/shared.ts` when that file exists (otherwise shared is empty) +- The **server** entry includes the composed client env (and therefore any shared variables) -`env/internal/shared.ts` is always part of the strict layout, even when you only define client and server variables. If nothing is shared, export an empty schema (`type({})`) — do not omit the file or the `SharedSchema` export. +Shared is not required for strict layout—omit `env/internal/shared.ts` when you have nothing to share. When the file is present, it must export `SharedSchema`. The shared schema path is fixed relative to your schema directory: `internal/shared.ts`. There is no module option to relocate it (for example to `env/hidden/shared.ts`). To compose different schemas — or to opt out of the default merge — pass an explicit `extends` list: @@ -157,9 +157,9 @@ See [Configuration & API](/docs/nuxt/configuration) for module options such as ` If you're using Standard Schema without ArkType, install `@arkenv/standard` instead of `@arkenv/core` — see [Zod, Valibot, and other Standard Schema validators](/docs/nuxt/using-other-validators). ::: - ### Define shared variables [step] [!toc] + ### Define shared variables (optional) [step] [!toc] - Create `env/internal/shared.ts` to define schema variables that are shared across both the client and the server. We treat this as a runtime type, so it is defined using PascalCase: + Optionally create `env/internal/shared.ts` to define schema variables that are shared across both the client and the server. Shared is not required for strict layout—omit the file when you have nothing to share. When present, we treat it as a runtime type, so it is defined using PascalCase: ```ts twoslash title="env/internal/shared.ts" import { type } from "@arkenv/core"; diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8768f0350..34b0b77d0 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -148,7 +148,11 @@ When working on a massive marketing push, docs facelift, or breaking API changes ## Preview deployments -PR previews for the `www` app are opt-in. Apply the `preview` label to a PR to trigger a Vercel preview deployment on `opened`, `synchronize`, and `ready_for_review` events (a preview is only produced when the `www` app is actually affected). Pushes to `dev` or `v1` continue to deploy rolling branch previews automatically. +PR previews for the `www` app are opt-in. A maintainer (triage+) applies the `preview` label to trigger a Vercel preview deployment when the label is added, and again on subsequent `synchronize` / `ready_for_review` events while the label remains (a preview is only produced when the `www` app is actually affected). This works for same-repo and fork PRs; fork authors cannot self-serve the label. + +Pushes to `dev` or `v1` always deploy via GitHub Actions (Vercel CLI). Those deploys pass git metadata and alias the rolling branch domains (`https://arkenv-dev.vercel.app`, `https://arkenv-v1.vercel.app`) so the domains stay current without relying on native Vercel Git builds. Labeled PR previews keep ephemeral deployment URLs and do not take over those branch domains. + +To redeploy an older commit to a stable URL without moving the branch, maintainers can run **Actions → Deploy www (manual SHA)** and choose `arkenv-dev.vercel.app`, `arkenv-v1.vercel.app`, or production `arkenv.js.org`. ## Changesets diff --git a/packages/arkenv/CHANGELOG.md b/packages/arkenv/CHANGELOG.md index d284d19b1..75f1d5b05 100644 --- a/packages/arkenv/CHANGELOG.md +++ b/packages/arkenv/CHANGELOG.md @@ -1,5 +1,36 @@ # @arkenv/core +## 1.0.0-alpha.11 + +### Patch Changes + +- #### Split `--help` options into Global and `init` sections _[`#1487`](https://github.com/yamcodes/arkenv/pull/1487) [`f86887c`](https://github.com/yamcodes/arkenv/commit/f86887c55c13c979451ff85d6d4ec5d3d69113dd) [@yamcodes](https://github.com/yamcodes)_ + + List shared flags under **Global options** and scaffolding flags under **init options**, matching the multi-command `/docs/cli` taxonomy. + + ```bash + npx arkenv@alpha --help + ``` + + ```text + Usage: + arkenv init [project-name] ... + arkenv add host [provider] ... + + Global options: + --yes, -y Skip prompts and use defaults ... + --quiet, -q Quiet mode ... + --json, -j Output structured JSON ... + --agent Enable non-interactive, machine-readable mode ... + --help, -h Show this help message + + init options: + --example Specify an example name ... + --force, -f Bypass checks and force scaffolding + --no-codegen Disable automatic env.gen.ts code generation ... + --host-preset, -H Specify a hosting provider preset ... + ``` + ## 1.0.0-alpha.10 ### Minor Changes diff --git a/packages/arkenv/package.json b/packages/arkenv/package.json index bca739229..16086b48c 100644 --- a/packages/arkenv/package.json +++ b/packages/arkenv/package.json @@ -1,7 +1,7 @@ { "name": "arkenv", "type": "module", - "version": "1.0.0-alpha.10", + "version": "1.0.0-alpha.11", "description": "Interactive CLI for scaffolding ArkEnv projects", "bin": { "arkenv": "./dist/index.cjs" diff --git a/packages/arkenv/src/cli/commands/help.test.ts b/packages/arkenv/src/cli/commands/help.test.ts index 137a23515..323250e50 100644 --- a/packages/arkenv/src/cli/commands/help.test.ts +++ b/packages/arkenv/src/cli/commands/help.test.ts @@ -5,7 +5,7 @@ import { version } from "../../../package.json"; import { HelpUseCase } from "./help"; describe("HelpUseCase", () => { - it("should display the help message with dynamic column alignment", async () => { + it("should display the help message with Global and init option sections", async () => { const logs: string[] = []; const logger = { log: vi.fn().mockImplementation((msg: string) => { @@ -37,27 +37,52 @@ describe("HelpUseCase", () => { " arkenv add host [provider] Add hosting provider preset (vercel, netlify, cloudflare, railway, render, fly) to schema", ); - // Options should be aligned based on the longest option (--host-preset, -H ) which is 26 chars - // leftPad (2) + longest flag (26) + colGap (4) = 32 characters start index for descriptions. + const globalHeaderIndex = logs.findIndex((l) => + l.includes(pc.bold("Global options:")), + ); + const initHeaderIndex = logs.findIndex((l) => + l.includes(pc.bold("init options:")), + ); + expect(globalHeaderIndex).toBeGreaterThan(-1); + expect(initHeaderIndex).toBeGreaterThan(globalHeaderIndex); + + // Global options align on the longest flag in that section (--quiet, -q = 11 chars) const yesOptionLog = logs.find((l) => l.includes("--yes, -y")); expect(yesOptionLog).toBeDefined(); - // "--yes, -y" is 9 chars. max (26) - 9 + colGap (4) = 21 spaces padding. + // "--yes, -y" is 9 chars. max (11) - 9 + colGap (4) = 6 spaces padding. expect(yesOptionLog).toBe( - " --yes, -y Skip prompts and use defaults (also passed to subprocesses)", + " --yes, -y Skip prompts and use defaults (also passed to subprocesses)", ); + expect(logs.indexOf(yesOptionLog as string)).toBeGreaterThan( + globalHeaderIndex, + ); + expect(logs.indexOf(yesOptionLog as string)).toBeLessThan(initHeaderIndex); + const agentOptionLog = logs.find((l) => l.includes("--agent")); + expect(agentOptionLog).toBeDefined(); + // "--agent" is 7 chars. max (11) - 7 + colGap (4) = 8 spaces padding. + expect(agentOptionLog).toBe( + " --agent Enable non-interactive, machine-readable mode for AI agents. Bypasses all prompts and outputs structured JSON. Macro for --yes --quiet --json", + ); + expect(logs.indexOf(agentOptionLog as string)).toBeLessThan( + initHeaderIndex, + ); + + const helpOptionLog = logs.find((l) => l.includes("--help, -h")); + expect(helpOptionLog).toBeDefined(); + // "--help, -h" is 10 chars. max (11) - 10 + colGap (4) = 5 spaces padding. + expect(helpOptionLog).toBe(" --help, -h Show this help message"); + expect(logs.indexOf(helpOptionLog as string)).toBeLessThan(initHeaderIndex); + + // Init options align on --host-preset, -H (26 chars) and must not appear under Global const exampleOptionLog = logs.find((l) => l.includes("--example")); expect(exampleOptionLog).toBeDefined(); // "--example" is 9 chars. max (26) - 9 + colGap (4) = 21 spaces padding. expect(exampleOptionLog).toBe( " --example Specify an example name when creating a new project", ); - - const agentOptionLog = logs.find((l) => l.includes("--agent")); - expect(agentOptionLog).toBeDefined(); - // "--agent" is 7 chars. max (26) - 7 + colGap (4) = 23 spaces padding. - expect(agentOptionLog).toBe( - " --agent Enable non-interactive, machine-readable mode for AI agents. Bypasses all prompts and outputs structured JSON. Macro for --yes --quiet --json", + expect(logs.indexOf(exampleOptionLog as string)).toBeGreaterThan( + initHeaderIndex, ); const noCodegenOptionLog = logs.find((l) => l.includes("--no-codegen")); @@ -66,6 +91,9 @@ describe("HelpUseCase", () => { expect(noCodegenOptionLog).toBe( " --no-codegen Disable automatic env.gen.ts code generation for Next.js", ); + expect(logs.indexOf(noCodegenOptionLog as string)).toBeGreaterThan( + initHeaderIndex, + ); const hostPresetOptionLog = logs.find((l) => l.includes("--host-preset, -H "), @@ -75,12 +103,14 @@ describe("HelpUseCase", () => { expect(hostPresetOptionLog).toBe( " --host-preset, -H Specify a hosting provider preset (none, vercel, netlify, cloudflare, railway, render, fly)", ); + expect(logs.indexOf(hostPresetOptionLog as string)).toBeGreaterThan( + initHeaderIndex, + ); - const helpOptionLog = logs.find((l) => l.includes("--help, -h")); - expect(helpOptionLog).toBeDefined(); - // "--help, -h" is 10 chars. max (26) - 10 + colGap (4) = 20 spaces padding. - expect(helpOptionLog).toBe( - " --help, -h Show this help message", + const forceOptionLog = logs.find((l) => l.includes("--force, -f")); + expect(forceOptionLog).toBeDefined(); + expect(logs.indexOf(forceOptionLog as string)).toBeGreaterThan( + initHeaderIndex, ); }); }); diff --git a/packages/arkenv/src/cli/commands/help.ts b/packages/arkenv/src/cli/commands/help.ts index e41da60a1..d970bf09a 100644 --- a/packages/arkenv/src/cli/commands/help.ts +++ b/packages/arkenv/src/cli/commands/help.ts @@ -45,15 +45,18 @@ export class HelpUseCase { }, ]; - const options: HelpItem[] = [ + const globalOptions: HelpItem[] = [ { left: "--yes, -y", right: "Skip prompts and use defaults (also passed to subprocesses)", }, { - left: "--force, -f", - right: - "Bypass technical requirement checks and dirty git working tree check, then force scaffolding", + left: "--quiet, -q", + right: "Quiet mode: Suppress output, capture logs on failure", + }, + { + left: "--json, -j", + right: "Output structured JSON to stdout", }, { left: "--agent", @@ -61,16 +64,20 @@ export class HelpUseCase { "Enable non-interactive, machine-readable mode for AI agents. Bypasses all prompts and outputs structured JSON. Macro for --yes --quiet --json", }, { - left: "--example", - right: "Specify an example name when creating a new project", + left: "--help, -h", + right: "Show this help message", }, + ]; + + const initOptions: HelpItem[] = [ { - left: "--quiet, -q", - right: "Quiet mode: Suppress output, capture logs on failure", + left: "--example", + right: "Specify an example name when creating a new project", }, { - left: "--json, -j", - right: "Output structured JSON to stdout", + left: "--force, -f", + right: + "Bypass technical requirement checks and dirty git working tree check, then force scaffolding", }, { left: "--no-codegen", @@ -81,10 +88,6 @@ export class HelpUseCase { right: "Specify a hosting provider preset (none, vercel, netlify, cloudflare, railway, render, fly)", }, - { - left: "--help, -h", - right: "Show this help message", - }, ]; this.logger.log(`ArkEnv CLI v${version}`); @@ -92,8 +95,12 @@ export class HelpUseCase { for (const line of formatColumns(commands)) { this.logger.log(line); } - this.logger.log(`\n${pc.bold("Options:")}`); - for (const line of formatColumns(options)) { + this.logger.log(`\n${pc.bold("Global options:")}`); + for (const line of formatColumns(globalOptions)) { + this.logger.log(line); + } + this.logger.log(`\n${pc.bold("init options:")}`); + for (const line of formatColumns(initOptions)) { this.logger.log(line); } } diff --git a/packages/arkenv/src/cli/commands/init.test.ts b/packages/arkenv/src/cli/commands/init.test.ts index 2f49f511b..6869b3ab4 100644 --- a/packages/arkenv/src/cli/commands/init.test.ts +++ b/packages/arkenv/src/cli/commands/init.test.ts @@ -65,7 +65,20 @@ describe("InitUseCase", () => { checkGitStatus: vi.fn().mockResolvedValue({ status: "clean" }), } as unknown as ProjectScannerPort; - useCase = new InitUseCase(logger, workspace, prompt, scanner); + const registry = { + fetchRegistry: vi.fn().mockResolvedValue({ + examples: [ + { + id: "basic", + name: "Basic", + description: "A minimal ArkEnv setup in Node.js", + framework: "vanilla", + }, + ], + }), + }; + + useCase = new InitUseCase(logger, workspace, prompt, scanner, registry); }); it("should enter new project flow if no package.json and empty directory", async () => { diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md index b9569e1f2..8ae17e8fb 100644 --- a/packages/build/CHANGELOG.md +++ b/packages/build/CHANGELOG.md @@ -1,5 +1,32 @@ # @arkenv/build +## 0.0.2-alpha.2 + +### Patch Changes + +- #### Make missing-schema errors short and actionable across hosts _[`#1495`](https://github.com/yamcodes/arkenv/pull/1495) [`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [@yamcodes](https://github.com/yamcodes)_ + + When a host cannot find an env schema, throw a consistent message that names the expected path / `schemaPath` and points to `npx arkenv@latest init`, without embedding a starter `env.ts` module. + + Example: + + ```text + [ArkEnv] Could not find schema file at src/env.ts or env.ts. Please specify 'schemaPath' in ArkEnv options (or run `npx arkenv@latest init`). + ``` + +- #### Make `env/internal/shared.ts` optional in strict layout _[`#1505`](https://github.com/yamcodes/arkenv/pull/1505) [`9bfe1c4`](https://github.com/yamcodes/arkenv/commit/9bfe1c4a6e278966ff2c0b2219d95e319888fb98) [@yamcodes](https://github.com/yamcodes)_ + + Strict layout now works with just `client.ts` and `server.ts`. Omit `internal/shared.ts` when you have nothing to share — shared keys are treated as empty. + + ```ts + // env/client.ts + env/server.ts alone is enough + export default withArkEnv(nextConfig, { + layout: "strict", + }); + ``` + + The CLI still scaffolds `shared.ts` by default for convenience. + ## 0.0.2-alpha.1 ### Patch Changes diff --git a/packages/build/package.json b/packages/build/package.json index b25e0f9e7..0ef7f6f43 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -1,6 +1,6 @@ { "name": "@arkenv/build", - "version": "0.0.2-alpha.1", + "version": "0.0.2-alpha.2", "description": "Shared build and codegen utilities for ArkEnv framework plugins", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/build/src/core.test.ts b/packages/build/src/core.test.ts index ca5cbd3e0..efeff65b9 100644 --- a/packages/build/src/core.test.ts +++ b/packages/build/src/core.test.ts @@ -1,7 +1,15 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { extractKeys, resolveLayout } from "./core"; +import { + assertFlatSchemaFile, + extractKeys, + findSchemaPath, + formatMissingSchemaError, + isStrictLayoutDir, + resolveLayout, +} from "./core"; describe("@arkenv/build layout resolution", () => { it("treats flat as simple layout", () => { @@ -18,6 +26,119 @@ describe("@arkenv/build layout resolution", () => { } }); + describe("strict layout detection", () => { + const makeTempDir = () => + fs.mkdtempSync(path.join(os.tmpdir(), "arkenv-build-layout-")); + + it("treats client.ts + server.ts as strict without internal/shared.ts", () => { + const tempDir = makeTempDir(); + try { + fs.writeFileSync( + path.join(tempDir, "client.ts"), + "export const env = {}", + ); + fs.writeFileSync( + path.join(tempDir, "server.ts"), + "export const env = {}", + ); + + expect(isStrictLayoutDir(tempDir)).toBe(true); + expect(resolveLayout(tempDir)).toEqual({ + layout: "strict", + baseDir: tempDir, + }); + expect(resolveLayout(path.join(tempDir, "client.ts"))).toEqual({ + layout: "strict", + baseDir: tempDir, + }); + expect( + resolveLayout(path.join(tempDir, "client.ts"), "strict"), + ).toEqual({ + layout: "strict", + baseDir: tempDir, + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("findSchemaPath discovers env/ without requiring shared.ts", () => { + const tempDir = makeTempDir(); + try { + const envDir = path.join(tempDir, "env"); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync( + path.join(envDir, "client.ts"), + "export const env = {}", + ); + fs.writeFileSync( + path.join(envDir, "server.ts"), + "export const env = {}", + ); + + expect(findSchemaPath(tempDir)).toBe(envDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("findSchemaPath discovers src/env/ without requiring shared.ts", () => { + const tempDir = makeTempDir(); + try { + const envDir = path.join(tempDir, "src", "env"); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync( + path.join(envDir, "client.ts"), + "export const env = {}", + ); + fs.writeFileSync( + path.join(envDir, "server.ts"), + "export const env = {}", + ); + + expect(findSchemaPath(tempDir)).toBe(envDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("throws for explicit strict when client.ts is missing", () => { + const tempDir = makeTempDir(); + try { + fs.writeFileSync( + path.join(tempDir, "server.ts"), + "export const env = {}", + ); + expect(() => + resolveLayout(path.join(tempDir, "missing.ts"), "strict"), + ).toThrow( + `[ArkEnv] Strict layout requires "${path.join(tempDir, "client.ts")}" to exist`, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("assertFlatSchemaFile rejects strict layout directories", () => { + const tempDir = makeTempDir(); + try { + fs.writeFileSync( + path.join(tempDir, "client.ts"), + "export const env = {}", + ); + fs.writeFileSync( + path.join(tempDir, "server.ts"), + "export const env = {}", + ); + expect(() => + assertFlatSchemaFile(tempDir, "ArkEnv Vite plugin:"), + ).toThrow(/only supports a flat env module file/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + it("extracts keys from flat layout schema with NUXT_PUBLIC_ prefix", () => { const content = ` export const env = arkenv({ @@ -49,3 +170,30 @@ describe("@arkenv/build layout resolution", () => { expect(res.clientKeys).toEqual(["NUXT_PUBLIC_API_URL"]); }); }); + +describe("formatMissingSchemaError", () => { + it("formats a short host error with npx arkenv@latest init and no starter", () => { + const message = formatMissingSchemaError({ + optionsHint: "setupArkEnv options", + }); + + expect(message).toBe( + "[ArkEnv] Could not find schema file at src/env.ts or env.ts. Please specify 'schemaPath' in setupArkEnv options (or run `npx arkenv@latest init`).", + ); + expect(message).not.toMatch(/```/); + expect(message).not.toMatch(/Example/); + }); + + it("includes checked paths when provided", () => { + const message = formatMissingSchemaError({ + prefix: "ArkEnv Vite plugin:", + optionsHint: "plugin options", + checkedPaths: ["/proj/src/env.ts", "/proj/env.ts"], + }); + + expect(message).toContain("ArkEnv Vite plugin: Could not find schema file"); + expect(message).toContain("Checked paths:"); + expect(message).toContain(" - /proj/src/env.ts"); + expect(message).toContain("npx arkenv@latest init"); + }); +}); diff --git a/packages/build/src/core.ts b/packages/build/src/core.ts index 716285f63..82811f66a 100644 --- a/packages/build/src/core.ts +++ b/packages/build/src/core.ts @@ -8,6 +8,12 @@ import { } from "@repo/log"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; +export { + DEFAULT_SCHEMA_LOCATIONS, + type FormatMissingSchemaErrorOptions, + formatMissingSchemaError, +} from "./missing-schema-error"; + // Global watcher reference isolated to this bundle's scope let activeWatcher: FSWatcher | undefined; @@ -21,23 +27,35 @@ export type ResolvedLayout = { baseDir: string; }; +/** + * Return whether a directory looks like a strict split layout. + * + * Strict auto-detection requires `client.ts` and `server.ts`. + * `internal/shared.ts` is optional and treated as empty when absent. + * + * @param dir Absolute path to a candidate env directory + * @returns `true` when both `client.ts` and `server.ts` exist + */ +export function isStrictLayoutDir(dir: string): boolean { + return ( + fs.existsSync(path.join(dir, "client.ts")) && + fs.existsSync(path.join(dir, "server.ts")) + ); +} + /** * Resolve the layout mode and base directory for a given schema file path. * * @param schemaPath The absolute path to the schema file or directory * @param layoutOption An optional explicit layout configuration ("flat", "simple", or "strict") * @returns An object containing the resolved layout mode and the base directory path - * @throws An error if explicit "strict" layout is requested but required split files are missing + * @throws An error if explicit "strict" layout is requested but `client.ts` is missing */ export function resolveLayout( schemaPath: string, layoutOption?: LayoutInput, ): ResolvedLayout { const layout = layoutOption === "flat" ? "simple" : layoutOption; - const checkStrict = (dir: string) => - fs.existsSync(path.join(dir, "internal", "shared.ts")) && - fs.existsSync(path.join(dir, "client.ts")) && - fs.existsSync(path.join(dir, "server.ts")); const resolveBaseDir = (p: string): string => { const ext = path.extname(p); @@ -54,7 +72,7 @@ export function resolveLayout( if (!layout) { const resolved = resolveBaseDir(schemaPath); if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { - if (checkStrict(resolved)) { + if (isStrictLayoutDir(resolved)) { return { layout: "strict", baseDir: resolved }; } return { layout: "simple", baseDir: resolved }; @@ -66,16 +84,16 @@ export function resolveLayout( if ( fs.existsSync(baseWithoutExt) && fs.statSync(baseWithoutExt).isDirectory() && - checkStrict(baseWithoutExt) + isStrictLayoutDir(baseWithoutExt) ) { return { layout: "strict", baseDir: baseWithoutExt }; } - if (checkStrict(parent)) { + if (isStrictLayoutDir(parent)) { return { layout: "strict", baseDir: parent }; } if ( path.basename(parent) === "internal" && - checkStrict(path.dirname(parent)) + isStrictLayoutDir(path.dirname(parent)) ) { return { layout: "strict", baseDir: path.dirname(parent) }; } @@ -97,12 +115,11 @@ export function resolveLayout( } const clientPath = path.join(baseDir, "client.ts"); - const sharedPath = path.join(baseDir, "internal", "shared.ts"); - if (!fs.existsSync(clientPath) || !fs.existsSync(sharedPath)) { + if (!fs.existsSync(clientPath)) { throw new Error( formatBuildError( - `Strict layout requires "${clientPath}" and "${sharedPath}" to exist. ` + - `Ensure both files are present or remove the 'layout: "strict"' option to let ArkEnv auto-detect.`, + `Strict layout requires "${clientPath}" to exist. ` + + `Ensure it is present or remove the 'layout: "strict"' option to let ArkEnv auto-detect.`, ), ); } @@ -113,6 +130,16 @@ export function resolveLayout( return { layout: "simple", baseDir: schemaPath }; } +/** + * Return the default absolute schema file candidates for a project root. + * + * @param cwd The working directory to search from (defaults to process.cwd()) + * @returns Absolute paths for `src/env.ts` and `env.ts` + */ +export function getDefaultSchemaFileCandidates(cwd = process.cwd()): string[] { + return [path.join(cwd, "src", "env.ts"), path.join(cwd, "env.ts")]; +} + /** * Find the path to the schema file or directory in the project. * @@ -120,28 +147,45 @@ export function resolveLayout( * @returns The absolute path to the schema file/directory, or null if not found */ export function findSchemaPath(cwd = process.cwd()): string | null { - const possiblePaths = [ - path.join(cwd, "src", "env.ts"), - path.join(cwd, "env.ts"), - ]; - for (const p of possiblePaths) { + for (const p of getDefaultSchemaFileCandidates(cwd)) { if (fs.existsSync(p)) return p; } const possibleDirs = [path.join(cwd, "src", "env"), path.join(cwd, "env")]; for (const d of possibleDirs) { - if ( - fs.existsSync(d) && - fs.existsSync(path.join(d, "internal", "shared.ts")) && - fs.existsSync(path.join(d, "client.ts")) && - fs.existsSync(path.join(d, "server.ts")) - ) { + if (fs.existsSync(d) && isStrictLayoutDir(d)) { return d; } } return null; } +/** + * Ensure a discovered schema path is a flat env module file. + * + * Vite/Bun plugins only support a single `env.ts` module. Strict layout + * directories discovered by {@link findSchemaPath} are rejected with a clear + * host-specific diagnostic. + * + * @param schemaPath Absolute path returned by discovery or plugin options + * @param prefix Brand prefix for the error (e.g. `"ArkEnv Vite plugin:"`) + * @returns The same `schemaPath` when it is an existing file + * @throws When `schemaPath` is a directory (typically a strict layout) + */ +export function assertFlatSchemaFile( + schemaPath: string, + prefix: string, +): string { + if (fs.existsSync(schemaPath) && fs.statSync(schemaPath).isDirectory()) { + throw new Error( + `${prefix} discovered a schema directory at "${schemaPath}". ` + + "This integration only supports a flat env module file (env.ts). " + + "Point schemaPath at that file, or use @arkenv/nextjs / @arkenv/nuxt for strict layout.", + ); + } + return schemaPath; +} + /** * Extract the schema and options arguments from an `arkenv` or `createEnv` call. */ diff --git a/packages/build/src/missing-schema-error.ts b/packages/build/src/missing-schema-error.ts new file mode 100644 index 000000000..ea73353d8 --- /dev/null +++ b/packages/build/src/missing-schema-error.ts @@ -0,0 +1,51 @@ +/** + * Default relative schema locations hosts search when `schemaPath` is omitted. + */ +export const DEFAULT_SCHEMA_LOCATIONS = "src/env.ts or env.ts"; + +export type FormatMissingSchemaErrorOptions = { + /** + * Explicit `schemaPath` that was missing. + * Omit to refer to {@link DEFAULT_SCHEMA_LOCATIONS}. + */ + schemaPath?: string; + /** + * Where to set `schemaPath` (e.g. `"setupArkEnv options"`, `"ArkEnv options"`, + * `"plugin options"`). + */ + optionsHint: string; + /** + * Brand prefix for the error. + * @default "[ArkEnv]" + */ + prefix?: string; + /** + * Absolute paths that were checked during discovery. + * Appended as a `Checked paths` section when present. + */ + checkedPaths?: string[]; +}; + +/** + * Format a missing-schema error shared by host integrations. + * + * Owns the no-starter policy: hosts must use this helper instead of inlining + * example `env.ts` modules in thrown errors. + * + * @param options Host-specific labels and optional discovery paths + * @returns A short, actionable missing-schema error message + */ +export function formatMissingSchemaError( + options: FormatMissingSchemaErrorOptions, +): string { + const prefix = options.prefix ?? "[ArkEnv]"; + const location = options.schemaPath || DEFAULT_SCHEMA_LOCATIONS; + let message = `${prefix} Could not find schema file at ${location}. Please specify 'schemaPath' in ${options.optionsHint} (or run \`npx arkenv@latest init\`).`; + + if (options.checkedPaths?.length) { + const pathsList = options.checkedPaths.map((p) => ` - ${p}`).join("\n"); + message += `\n\nChecked paths:\n${pathsList}`; + } + + return message; +} diff --git a/packages/bun-plugin/CHANGELOG.md b/packages/bun-plugin/CHANGELOG.md index 9aec52911..3cd97f96a 100644 --- a/packages/bun-plugin/CHANGELOG.md +++ b/packages/bun-plugin/CHANGELOG.md @@ -1,5 +1,31 @@ # @arkenv/bun-plugin +## 1.0.0-alpha.8 + +### Patch Changes + +- #### Make missing-schema errors short and actionable across hosts _[`#1495`](https://github.com/yamcodes/arkenv/pull/1495) [`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [@yamcodes](https://github.com/yamcodes)_ + + When a host cannot find an env schema, throw a consistent message that names the expected path / `schemaPath` and points to `npx arkenv@latest init`, without embedding a starter `env.ts` module. + + Example: + + ```text + [ArkEnv] Could not find schema file at src/env.ts or env.ts. Please specify 'schemaPath' in ArkEnv options (or run `npx arkenv@latest init`). + ``` + +
Updated 1 dependency + + + +[`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [`9bfe1c4`](https://github.com/yamcodes/arkenv/commit/9bfe1c4a6e278966ff2c0b2219d95e319888fb98) + + + +- `@arkenv/build@0.0.2-alpha.2` + +
+ ## 1.0.0-alpha.7 ### Minor Changes diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json index ec933074a..8b90ffaaf 100644 --- a/packages/bun-plugin/package.json +++ b/packages/bun-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@arkenv/bun-plugin", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "author": "Yam Borodetsky ", "repository": { "type": "git", diff --git a/packages/bun-plugin/src/env-module-path.ts b/packages/bun-plugin/src/env-module-path.ts index 18be2fff2..8f646dd02 100644 --- a/packages/bun-plugin/src/env-module-path.ts +++ b/packages/bun-plugin/src/env-module-path.ts @@ -1,6 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import { findSchemaPath } from "@arkenv/build"; +import { + assertFlatSchemaFile, + findSchemaPath, + formatMissingSchemaError, + getDefaultSchemaFileCandidates, +} from "@arkenv/build"; /** * Strip query suffixes from a module path. @@ -55,16 +60,20 @@ export function resolveEnvModulePath( `ArkEnv Bun plugin: schemaPath "${schemaPath}" does not exist (resolved to "${resolved}").`, ); } - return resolved; + return assertFlatSchemaFile(resolved, "ArkEnv Bun plugin:"); } const discovered = findSchemaPath(root); if (!discovered) { throw new Error( - `ArkEnv Bun plugin: could not find an env module. Expected "src/env.ts" or "env.ts" under "${root}", or pass schemaPath (or run \`arkenv init\`).`, + formatMissingSchemaError({ + prefix: "ArkEnv Bun plugin:", + optionsHint: "plugin options", + checkedPaths: getDefaultSchemaFileCandidates(root), + }), ); } - return discovered; + return assertFlatSchemaFile(discovered, "ArkEnv Bun plugin:"); } /** diff --git a/packages/bun-plugin/src/env-module.test.ts b/packages/bun-plugin/src/env-module.test.ts index 233011325..2651b8a94 100644 --- a/packages/bun-plugin/src/env-module.test.ts +++ b/packages/bun-plugin/src/env-module.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -297,3 +297,48 @@ describe("SPA mode regression", () => { delete process.env.BUN_PUBLIC_TEST; }); }); + +describe("missing-schema errors", () => { + const temps: string[] = []; + + afterEach(() => { + for (const dir of temps.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("throws a short discovery error without an env.ts starter", async () => { + const { resolveEnvModulePath } = await import("./env-module-path.js"); + const root = mkdtempSync(join(tmpdir(), "arkenv-bun-missing-schema-")); + temps.push(root); + + let message = ""; + try { + resolveEnvModulePath(root); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toMatch(/Could not find schema file/); + expect(message).toMatch(/npx arkenv@latest init/); + expect(message).toMatch(/Checked paths:/); + expect(message).not.toMatch(/Example `src\/env\.ts`/); + expect(message).not.toMatch(/```/); + expect(message).not.toMatch(/import \{ type \} from "arktype"/); + expect(message).not.toMatch(/from "zod"/); + }); + + it("rejects a discovered strict layout directory", async () => { + const { resolveEnvModulePath } = await import("./env-module-path.js"); + const root = mkdtempSync(join(tmpdir(), "arkenv-bun-strict-dir-")); + temps.push(root); + const envDir = join(root, "env"); + mkdirSync(envDir, { recursive: true }); + writeFileSync(join(envDir, "client.ts"), "export const env = {}"); + writeFileSync(join(envDir, "server.ts"), "export const env = {}"); + + expect(() => resolveEnvModulePath(root)).toThrow( + /only supports a flat env module file/, + ); + }); +}); diff --git a/packages/nextjs/CHANGELOG.md b/packages/nextjs/CHANGELOG.md index e73a5a1e0..97d2c9376 100644 --- a/packages/nextjs/CHANGELOG.md +++ b/packages/nextjs/CHANGELOG.md @@ -1,5 +1,48 @@ # @arkenv/nextjs +## 1.0.0-alpha.9 + +### Patch Changes + +- #### Align missing-schema errors with short, actionable host guidance _[`#1488`](https://github.com/yamcodes/arkenv/pull/1488) [`9d5bdbb`](https://github.com/yamcodes/arkenv/commit/9d5bdbbeaf2fdddf69f5bcc47a7d79b15a51ece3) [@yamcodes](https://github.com/yamcodes)_ + + Point missing-schema errors at checked paths / `schemaPath` and `arkenv init`, matching the Bun plugin style, without embedding starter `env.ts` modules. + +- #### Make missing-schema errors short and actionable across hosts _[`#1495`](https://github.com/yamcodes/arkenv/pull/1495) [`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [@yamcodes](https://github.com/yamcodes)_ + + When a host cannot find an env schema, throw a consistent message that names the expected path / `schemaPath` and points to `npx arkenv@latest init`, without embedding a starter `env.ts` module. + + Example: + + ```text + [ArkEnv] Could not find schema file at src/env.ts or env.ts. Please specify 'schemaPath' in ArkEnv options (or run `npx arkenv@latest init`). + ``` + +- #### Make `env/internal/shared.ts` optional in strict layout _[`#1505`](https://github.com/yamcodes/arkenv/pull/1505) [`9bfe1c4`](https://github.com/yamcodes/arkenv/commit/9bfe1c4a6e278966ff2c0b2219d95e319888fb98) [@yamcodes](https://github.com/yamcodes)_ + + Strict layout now works with just `client.ts` and `server.ts`. Omit `internal/shared.ts` when you have nothing to share — shared keys are treated as empty. + + ```ts + // env/client.ts + env/server.ts alone is enough + export default withArkEnv(nextConfig, { + layout: "strict", + }); + ``` + + The CLI still scaffolds `shared.ts` by default for convenience. + +
Updated 1 dependency + + + +[`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [`9bfe1c4`](https://github.com/yamcodes/arkenv/commit/9bfe1c4a6e278966ff2c0b2219d95e319888fb98) + + + +- `@arkenv/build@0.0.2-alpha.2` + +
+ ## 1.0.0-alpha.8 ### Patch Changes diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 41ed07e91..ab8077b1a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@arkenv/nextjs", - "version": "1.0.0-alpha.8", + "version": "1.0.0-alpha.9", "author": "Yam Borodetsky ", "repository": { "type": "git", diff --git a/packages/nextjs/src/config/setup.ts b/packages/nextjs/src/config/setup.ts index a8efa6fc1..a6efa3eb5 100644 --- a/packages/nextjs/src/config/setup.ts +++ b/packages/nextjs/src/config/setup.ts @@ -1,7 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { findSchemaPath, resolveLayout, watchSchema } from "@arkenv/build"; +import { + findSchemaPath, + formatMissingSchemaError, + resolveLayout, + watchSchema, +} from "@arkenv/build"; import { formatBuildError, resolveBuildLog, @@ -56,11 +61,10 @@ export function setupArkEnv( if (!schemaPath || !schemaPathExists(schemaPath)) { throw new Error( - formatBuildError( - `Could not find schema file at ${ - options?.schemaPath || "src/env.ts or env.ts" - }. Please specify 'schemaPath' in setupArkEnv options.`, - ), + formatMissingSchemaError({ + schemaPath: options?.schemaPath, + optionsHint: "setupArkEnv options", + }), ); } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index ee89f5faf..1646022e7 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -46,7 +46,7 @@ export type ArkEnvConfigOptions = { * Specify the configuration layout. * * - `"flat"` (default): A single `env.ts` schema file. - * - `"strict"`: A 3-file split schema layout (`env/internal/shared.ts`, `env/client.ts`, `env/server.ts`). + * - `"strict"`: A split schema layout (`env/client.ts`, `env/server.ts`, and optionally `env/internal/shared.ts`). * * @default "flat" */ diff --git a/packages/nuxt/CHANGELOG.md b/packages/nuxt/CHANGELOG.md index 6578c3b8e..6966fc2ce 100644 --- a/packages/nuxt/CHANGELOG.md +++ b/packages/nuxt/CHANGELOG.md @@ -1,5 +1,48 @@ # @arkenv/nuxt +## 1.0.0-alpha.11 + +### Patch Changes + +- #### Align missing-schema errors with short, actionable host guidance _[`#1488`](https://github.com/yamcodes/arkenv/pull/1488) [`9d5bdbb`](https://github.com/yamcodes/arkenv/commit/9d5bdbbeaf2fdddf69f5bcc47a7d79b15a51ece3) [@yamcodes](https://github.com/yamcodes)_ + + Point missing-schema errors at checked paths / `schemaPath` and `arkenv init`, matching the Bun plugin style, without embedding starter `env.ts` modules. + +- #### Make missing-schema errors short and actionable across hosts _[`#1495`](https://github.com/yamcodes/arkenv/pull/1495) [`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [@yamcodes](https://github.com/yamcodes)_ + + When a host cannot find an env schema, throw a consistent message that names the expected path / `schemaPath` and points to `npx arkenv@latest init`, without embedding a starter `env.ts` module. + + Example: + + ```text + [ArkEnv] Could not find schema file at src/env.ts or env.ts. Please specify 'schemaPath' in ArkEnv options (or run `npx arkenv@latest init`). + ``` + +- #### Make `env/internal/shared.ts` optional in strict layout _[`#1505`](https://github.com/yamcodes/arkenv/pull/1505) [`9bfe1c4`](https://github.com/yamcodes/arkenv/commit/9bfe1c4a6e278966ff2c0b2219d95e319888fb98) [@yamcodes](https://github.com/yamcodes)_ + + Strict layout now works with just `client.ts` and `server.ts`. Omit `internal/shared.ts` when you have nothing to share — shared keys are treated as empty. + + ```ts + // env/client.ts + env/server.ts alone is enough + export default withArkEnv(nextConfig, { + layout: "strict", + }); + ``` + + The CLI still scaffolds `shared.ts` by default for convenience. + +
Updated 1 dependency + + + +[`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [`9bfe1c4`](https://github.com/yamcodes/arkenv/commit/9bfe1c4a6e278966ff2c0b2219d95e319888fb98) + + + +- `@arkenv/build@0.0.2-alpha.2` + +
+ ## 1.0.0-alpha.10 ### Major Changes diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index ccd6a0d0a..d6a36884d 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@arkenv/nuxt", - "version": "1.0.0-alpha.10", + "version": "1.0.0-alpha.11", "author": "Yam Borodetsky ", "repository": { "type": "git", diff --git a/packages/nuxt/src/boot-gate.ts b/packages/nuxt/src/boot-gate.ts index 1ffc7f67a..47e0ddf31 100644 --- a/packages/nuxt/src/boot-gate.ts +++ b/packages/nuxt/src/boot-gate.ts @@ -278,24 +278,22 @@ export function loadSchemaViaCapture( "internal", "shared.ts", ); - if (!fs.existsSync(strictUserSharedPath)) { - throw new Error( - `[arkenv] Strict layout requires "internal/shared.ts" with a usable SharedSchema export under "${config.baseDir}".`, - ); - } - - const sharedMod = jiti(strictUserSharedPath) as { - SharedSchema?: SchemaShape; - default?: { SharedSchema?: SchemaShape }; - }; - const sharedSchema = - sharedMod.SharedSchema ?? sharedMod.default?.SharedSchema; - if (sharedSchema === undefined || sharedSchema === null) { - throw new Error( - `[arkenv] Strict layout requires a usable SharedSchema export from "${strictUserSharedPath}".`, - ); + // Absent shared.ts → empty merge (aliases already point at + // empty-shared-schema). A present file must export SharedSchema. + if (fs.existsSync(strictUserSharedPath)) { + const sharedMod = jiti(strictUserSharedPath) as { + SharedSchema?: SchemaShape; + default?: { SharedSchema?: SchemaShape }; + }; + const sharedSchema = + sharedMod.SharedSchema ?? sharedMod.default?.SharedSchema; + if (sharedSchema === undefined || sharedSchema === null) { + throw new Error( + `[arkenv] Strict layout requires a usable SharedSchema export from "${strictUserSharedPath}".`, + ); + } + g.__ARKENV_SHARED_SCHEMA__ = sharedSchema; } - g.__ARKENV_SHARED_SCHEMA__ = sharedSchema; const strictUserClientPath = path.join(config.baseDir, "client.ts"); if (fs.existsSync(strictUserClientPath)) { diff --git a/packages/nuxt/src/config.ts b/packages/nuxt/src/config.ts index 3eda354c5..175de2c48 100644 --- a/packages/nuxt/src/config.ts +++ b/packages/nuxt/src/config.ts @@ -5,11 +5,11 @@ import { extractClientKeys, extractSharedKeys, findSchemaPath, + formatMissingSchemaError, resolveLayout, } from "@arkenv/build"; import { type BuildLogHelpers, - formatBuildError, type Logger, type LogLevel, resolveBuildLog, @@ -26,6 +26,7 @@ export { extractArkenvBlock, extractServerKeys, findSchemaPath, + formatMissingSchemaError, resolveLayout, } from "@arkenv/build"; export { extractClientKeys, extractSharedKeys, validateSchema }; @@ -72,12 +73,12 @@ export type ArkEnvConfigOptions = { * Specify the configuration layout. * * When omitted, the layout is auto-detected from the schema structure: it is - * `"strict"` when the split files (`env/internal/shared.ts`, `env/client.ts`, - * `env/server.ts`) are present, and falls back to `"flat"` (a single + * `"strict"` when `env/client.ts` and `env/server.ts` are present (with + * optional `env/internal/shared.ts`), and falls back to `"flat"` (a single * `env.ts`) otherwise. * * - `"flat"`: A single `env.ts` schema file. - * - `"strict"`: A multi-file split schema layout. + * - `"strict"`: A split schema layout (`env/client.ts`, `env/server.ts`, and optionally `env/internal/shared.ts`). */ layout?: | "flat" @@ -130,11 +131,10 @@ export function setupArkEnv( if (!schemaPath || !exists) { throw new Error( - formatBuildError( - `Could not find schema file at ${ - options?.schemaPath || "src/env.ts or env.ts" - }. Please specify 'schemaPath' in ArkEnv options.`, - ), + formatMissingSchemaError({ + schemaPath: options?.schemaPath, + optionsHint: "ArkEnv options", + }), ); } diff --git a/packages/nuxt/src/empty-shared-schema.ts b/packages/nuxt/src/empty-shared-schema.ts index 051ea3ad3..66b98c67d 100644 --- a/packages/nuxt/src/empty-shared-schema.ts +++ b/packages/nuxt/src/empty-shared-schema.ts @@ -1,6 +1,7 @@ /** * Empty shared schema used when `#arkenv/shared-schema` is imported outside - * strict layout (so the client entry can keep a static import without Vite - * failing to resolve the virtual specifier). + * strict layout, or in strict layout when `env/internal/shared.ts` is omitted + * (so the client entry can keep a static import without Vite failing to + * resolve the virtual specifier). */ export const SharedSchema = {}; diff --git a/packages/nuxt/src/module.test.ts b/packages/nuxt/src/module.test.ts index 6f9c24624..b42fa5366 100644 --- a/packages/nuxt/src/module.test.ts +++ b/packages/nuxt/src/module.test.ts @@ -340,13 +340,14 @@ describe("Nuxt module integration", () => { } }); - it("throws when strict layout is missing internal/shared.ts", async () => { + it("aliases #arkenv/shared-schema to empty stub when internal/shared.ts is omitted", async () => { const tempDir = path.resolve(__dirname, "temp-strict-missing-shared"); const envDir = path.join(tempDir, "env"); fs.mkdirSync(envDir, { recursive: true }); try { - fs.writeFileSync(path.join(envDir, "client.ts"), "export const env = {}"); + const clientPath = path.join(envDir, "client.ts"); + fs.writeFileSync(clientPath, "export const env = {}"); fs.writeFileSync(path.join(envDir, "server.ts"), "export const env = {}"); const mockNuxt: any = { @@ -355,20 +356,29 @@ describe("Nuxt module integration", () => { rootDir: tempDir, srcDir: tempDir, runtimeConfig: { public: {} }, + alias: {}, }, hook: vi.fn(), }; - expect(() => - (module as any).setup( - { - schemaPath: "./env", - layout: "strict", - validate: false, - }, - mockNuxt, - ), - ).toThrow(/internal\/shared\.ts/); + await (module as any).setup( + { + schemaPath: "./env", + layout: "strict", + validate: false, + }, + mockNuxt, + ); + + expect(mockNuxt.options.alias["#arkenv/client-env"]).toBe(clientPath); + const sharedAlias = mockNuxt.options.alias[ + "#arkenv/shared-schema" + ] as string; + expect(sharedAlias).toBeDefined(); + expect(sharedAlias).toMatch(/empty-shared-schema/); + expect(fs.existsSync(path.join(envDir, "internal", "shared.ts"))).toBe( + false, + ); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -545,11 +555,19 @@ describe("Nuxt module integration", () => { }; try { - expect(() => - (module as any).setup({ validate: false }, mockNuxt), - ).toThrow( + let message = ""; + try { + (module as any).setup({ validate: false }, mockNuxt); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toMatch( /\[ArkEnv\] Could not find schema file at src\/env\.ts or env\.ts/, ); + expect(message).toMatch(/npx arkenv@latest init/); + expect(message).not.toMatch(/Example `src\/env\.ts`/); + expect(message).not.toMatch(/```/); expect(mockNuxt.hook).not.toHaveBeenCalled(); expect(mockNuxt.options.runtimeConfig.DATABASE_URL).toBeUndefined(); } finally { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index c93f80d9c..979d38fc4 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -12,6 +12,7 @@ import { extractServerKeys, extractSharedKeys, findSchemaPath, + formatMissingSchemaError, normalizeLayout, resolveLayout, validateSchema, @@ -22,7 +23,6 @@ import { registerStrictLayoutHooks, registerViteExtendHook, } from "./strict-layout-hooks"; -import { missingSharedTsError } from "./strict-shared-schema"; /** * Configuration options for the ArkEnv Nuxt module. @@ -80,9 +80,10 @@ const module: NuxtModule = defineNuxtModule({ if (!schemaPath || !fs.existsSync(schemaPath)) { throw new Error( - `[ArkEnv] Could not find schema file at ${ - options.schemaPath || "src/env.ts or env.ts" - }. Please specify 'schemaPath' in ArkEnv options.`, + formatMissingSchemaError({ + schemaPath: options.schemaPath, + optionsHint: "ArkEnv options", + }), ); } @@ -129,19 +130,21 @@ const module: NuxtModule = defineNuxtModule({ nuxt.options.srcDir ?? nuxt.options.rootDir, ); + const emptySharedSchema = resolver.resolve("./empty-shared-schema"); + let strictClientPath: string | undefined; let strictSharedPath: string | undefined; + let userSharedPath: string | undefined; if (resolvedLayout === "strict" && baseDir) { const clientPath = path.join(baseDir, "client.ts"); if (!fs.existsSync(clientPath)) { throw new Error(missingClientTsError(clientPath, baseDir)); } const sharedPath = path.join(baseDir, "internal", "shared.ts"); - if (!fs.existsSync(sharedPath)) { - throw new Error(missingSharedTsError(sharedPath, baseDir)); - } + userSharedPath = fs.existsSync(sharedPath) ? sharedPath : undefined; strictClientPath = clientPath; - strictSharedPath = sharedPath; + // Missing shared.ts is intentional empty; alias to the package stub. + strictSharedPath = userSharedPath ?? emptySharedSchema; } if (nuxt.options.dev) { @@ -188,7 +191,9 @@ const module: NuxtModule = defineNuxtModule({ const serverPath = path.join(baseDir, "server.ts"); const clientContent = fs.readFileSync(strictClientPath, "utf-8"); - const sharedContent = fs.readFileSync(strictSharedPath, "utf-8"); + const sharedContent = userSharedPath + ? fs.readFileSync(userSharedPath, "utf-8") + : ""; const serverContent = fs.existsSync(serverPath) ? fs.readFileSync(serverPath, "utf-8") : ""; diff --git a/packages/nuxt/src/strict-layout-hooks.ts b/packages/nuxt/src/strict-layout-hooks.ts index f960a3066..d69a91954 100644 --- a/packages/nuxt/src/strict-layout-hooks.ts +++ b/packages/nuxt/src/strict-layout-hooks.ts @@ -28,7 +28,8 @@ declare module "@nuxt/schema" { * * @param nuxt The Nuxt instance * @param strictClientPath Absolute path to the project's `env/client.ts` - * @param strictSharedPath Absolute path to the project's `env/internal/shared.ts` + * @param strictSharedPath Absolute path to the project's `env/internal/shared.ts`, + * or the package `empty-shared-schema` stub when that file is omitted */ export function registerStrictLayoutHooks( nuxt: Nuxt, @@ -150,7 +151,7 @@ export function registerViteExtendHook( id === CLIENT_ENV_SPECIFIER || id === `\0${CLIENT_ENV_SPECIFIER}` ) { - if (strictClientPath && fs.existsSync(strictClientPath)) { + if (fs.existsSync(strictClientPath)) { return strictClientPath; } throw new Error(UNRESOLVED_CLIENT_ENV_ERROR); @@ -165,7 +166,8 @@ export function registerViteExtendHook( id === SHARED_SCHEMA_SPECIFIER || id === `\0${SHARED_SCHEMA_SPECIFIER}` ) { - if (strictSharedPath && fs.existsSync(strictSharedPath)) { + // `strictSharedPath` is either the user file or empty-shared-schema. + if (fs.existsSync(strictSharedPath)) { return strictSharedPath; } throw new Error(UNRESOLVED_SHARED_SCHEMA_ERROR); diff --git a/packages/nuxt/src/strict-shared-schema.ts b/packages/nuxt/src/strict-shared-schema.ts index 7bcd95904..0374b9221 100644 --- a/packages/nuxt/src/strict-shared-schema.ts +++ b/packages/nuxt/src/strict-shared-schema.ts @@ -1,11 +1,9 @@ -import { formatBuildError } from "@repo/log"; - export const SHARED_SCHEMA_SPECIFIER = "#arkenv/shared-schema"; export const UNRESOLVED_SHARED_SCHEMA_ERROR = "[arkenv] Could not resolve #arkenv/shared-schema.\n" + - "Ensure @arkenv/nuxt/module is registered in nuxt.config and env/internal/shared.ts\n" + - "exports SharedSchema, or pass extends: [SharedSchema] explicitly."; + "Ensure @arkenv/nuxt/module is registered in nuxt.config. When present,\n" + + "env/internal/shared.ts must export SharedSchema, or pass extends: [SharedSchema] explicitly."; type GlobalStrictState = { __ARKENV_SHARED_SCHEMA__?: unknown; @@ -16,7 +14,8 @@ type GlobalStrictState = { * * Prefers the Jiti validation injection on `globalThis`, then falls back to * the statically imported `#arkenv/shared-schema` module (aliased by the Nuxt - * module). Never uses `node:module` / `createRequire`. + * module to the project file or `empty-shared-schema` when absent). Never uses + * `node:module` / `createRequire`. * * @param importedSharedSchema The `SharedSchema` export from `#arkenv/shared-schema` * @returns The shared schema to pass through `extends` @@ -36,23 +35,3 @@ export function resolveStrictSharedSchema( throw new Error(UNRESOLVED_SHARED_SCHEMA_ERROR); } - -/** - * Build the fail-fast error for a missing strict-layout `internal/shared.ts`. - * - * @param sharedPath The absolute path where `internal/shared.ts` was expected - * @param baseDir The strict layout base directory - * @returns A formatted ArkEnv build error message - */ -export function missingSharedTsError( - sharedPath: string, - baseDir: string, -): string { - return formatBuildError( - `Strict layout requires "internal/shared.ts" but it was not found at "${sharedPath}".\n` + - `Expected strict layout structure under "${baseDir}":\n` + - " ├── internal/shared.ts\n" + - " ├── client.ts\n" + - " └── server.ts", - ); -} diff --git a/packages/nuxt/src/validation.test.ts b/packages/nuxt/src/validation.test.ts index 3156ea409..e0dceb6df 100644 --- a/packages/nuxt/src/validation.test.ts +++ b/packages/nuxt/src/validation.test.ts @@ -287,6 +287,41 @@ describe("build-time environment validation", () => { }).toThrow(/SharedSchema/); }); + it("should pass when internal/shared.ts is omitted", () => { + fs.writeFileSync( + clientPath, + ` + import arkenv from "@arkenv/nuxt/client"; + export const env = arkenv({ + NUXT_PUBLIC_API_URL: "string", + }); + `, + "utf-8", + ); + + fs.writeFileSync( + serverPath, + ` + import arkenv from "${path.resolve(__dirname, "./server.ts")}"; + export const env = arkenv({ + DATABASE_URL: "string", + }); + `, + "utf-8", + ); + + process.env.DATABASE_URL = "postgres://localhost/db"; + process.env.NUXT_PUBLIC_API_URL = "https://api.example.com"; + + expect(() => { + setupArkEnv({ + schemaPath: strictBaseDir, + layout: "strict", + validate: true, + }); + }).not.toThrow(); + }, 15_000); + it("should still honor explicit extends in strict layout validation", () => { fs.writeFileSync( sharedPath, diff --git a/packages/vite-plugin/CHANGELOG.md b/packages/vite-plugin/CHANGELOG.md index 50b49b902..c3a0b0a64 100644 --- a/packages/vite-plugin/CHANGELOG.md +++ b/packages/vite-plugin/CHANGELOG.md @@ -1,5 +1,35 @@ # @arkenv/vite-plugin +## 1.0.0-alpha.8 + +### Patch Changes + +- #### Align missing-schema errors with short, actionable host guidance _[`#1488`](https://github.com/yamcodes/arkenv/pull/1488) [`9d5bdbb`](https://github.com/yamcodes/arkenv/commit/9d5bdbbeaf2fdddf69f5bcc47a7d79b15a51ece3) [@yamcodes](https://github.com/yamcodes)_ + + Point missing-schema errors at checked paths / `schemaPath` and `arkenv init`, matching the Bun plugin style, without embedding starter `env.ts` modules. + +- #### Make missing-schema errors short and actionable across hosts _[`#1495`](https://github.com/yamcodes/arkenv/pull/1495) [`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [@yamcodes](https://github.com/yamcodes)_ + + When a host cannot find an env schema, throw a consistent message that names the expected path / `schemaPath` and points to `npx arkenv@latest init`, without embedding a starter `env.ts` module. + + Example: + + ```text + [ArkEnv] Could not find schema file at src/env.ts or env.ts. Please specify 'schemaPath' in ArkEnv options (or run `npx arkenv@latest init`). + ``` + +
Updated 1 dependency + + + +[`3785c6b`](https://github.com/yamcodes/arkenv/commit/3785c6bfa27888a669900045b5b326e7baa1558b) [`9bfe1c4`](https://github.com/yamcodes/arkenv/commit/9bfe1c4a6e278966ff2c0b2219d95e319888fb98) + + + +- `@arkenv/build@0.0.2-alpha.2` + +
+ ## 1.0.0-alpha.7 ### Minor Changes diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index d2735f4d1..df42434b6 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@arkenv/vite-plugin", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "author": "Yam Borodetsky ", "repository": { "type": "git", diff --git a/packages/vite-plugin/src/env-module-path.ts b/packages/vite-plugin/src/env-module-path.ts index ab1532a69..abda94a41 100644 --- a/packages/vite-plugin/src/env-module-path.ts +++ b/packages/vite-plugin/src/env-module-path.ts @@ -1,6 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import { findSchemaPath } from "@arkenv/build"; +import { + assertFlatSchemaFile, + findSchemaPath, + formatMissingSchemaError, + getDefaultSchemaFileCandidates, +} from "@arkenv/build"; /** * Strip Vite virtual-module and query suffixes from a module id. @@ -58,16 +63,20 @@ export function resolveEnvModulePath( `ArkEnv Vite plugin: schemaPath "${schemaPath}" does not exist (resolved to "${resolved}").`, ); } - return resolved; + return assertFlatSchemaFile(resolved, "ArkEnv Vite plugin:"); } const discovered = findSchemaPath(root); if (!discovered) { throw new Error( - `ArkEnv Vite plugin: could not find an env module. Expected "src/env.ts" or "env.ts" under "${root}", or pass schemaPath.`, + formatMissingSchemaError({ + prefix: "ArkEnv Vite plugin:", + optionsHint: "plugin options", + checkedPaths: getDefaultSchemaFileCandidates(root), + }), ); } - return discovered; + return assertFlatSchemaFile(discovered, "ArkEnv Vite plugin:"); } /** diff --git a/packages/vite-plugin/src/env-module.test.ts b/packages/vite-plugin/src/env-module.test.ts index d47f96572..b6b399079 100644 --- a/packages/vite-plugin/src/env-module.test.ts +++ b/packages/vite-plugin/src/env-module.test.ts @@ -1,4 +1,10 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import * as vite from "vite"; @@ -244,3 +250,48 @@ describe("transform mode plugin", () => { expect(result?.code).not.toContain("@arkenv/core"); }); }); + +describe("missing-schema errors", () => { + const temps: string[] = []; + + afterEach(() => { + for (const dir of temps.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("throws a short discovery error without an env.ts starter", async () => { + const { resolveEnvModulePath } = await import("./env-module-path.js"); + const root = mkdtempSync(join(tmpdir(), "arkenv-vite-missing-schema-")); + temps.push(root); + + let message = ""; + try { + resolveEnvModulePath(root); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toMatch(/Could not find schema file/); + expect(message).toMatch(/npx arkenv@latest init/); + expect(message).toMatch(/Checked paths:/); + expect(message).not.toMatch(/Example `src\/env\.ts`/); + expect(message).not.toMatch(/```/); + expect(message).not.toMatch(/import \{ type \} from "arktype"/); + expect(message).not.toMatch(/from "zod"/); + }); + + it("rejects a discovered strict layout directory", async () => { + const { resolveEnvModulePath } = await import("./env-module-path.js"); + const root = mkdtempSync(join(tmpdir(), "arkenv-vite-strict-dir-")); + temps.push(root); + const envDir = join(root, "env"); + mkdirSync(envDir, { recursive: true }); + writeFileSync(join(envDir, "client.ts"), "export const env = {}"); + writeFileSync(join(envDir, "server.ts"), "export const env = {}"); + + expect(() => resolveEnvModulePath(root)).toThrow( + /only supports a flat env module file/, + ); + }); +});