From 18733e397b1946e57eb65a67376bed5789b62e01 Mon Sep 17 00:00:00 2001 From: TL Date: Wed, 6 May 2026 08:57:48 +0800 Subject: [PATCH 1/3] chore(envy): fix formatting issue in CHANGELOG --- packages/envy/CHANGELOG.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/envy/CHANGELOG.md b/packages/envy/CHANGELOG.md index b0b0191..f0e8e1a 100644 --- a/packages/envy/CHANGELOG.md +++ b/packages/envy/CHANGELOG.md @@ -2,10 +2,7 @@ ## [1.0.3](https://github.com/its-tim-lee/developwise/compare/envy-v1.0.2...envy-v1.0.3) (2026-05-06) - ### Bug Fixes -* **envy:** update pacakge.json to test release-please ([ae43010](https://github.com/its-tim-lee/developwise/commit/ae43010e1ae00e9a336bb2bb5c1c36f42782b642)) -* **system:** typo ([0b6ebb0](https://github.com/its-tim-lee/developwise/commit/0b6ebb0c22b9829c79d56151abba03ef3be05552)) - -## Changelog +- **envy:** update pacakge.json to test release-please ([ae43010](https://github.com/its-tim-lee/developwise/commit/ae43010e1ae00e9a336bb2bb5c1c36f42782b642)) +- **system:** typo ([0b6ebb0](https://github.com/its-tim-lee/developwise/commit/0b6ebb0c22b9829c79d56151abba03ef3be05552)) From 84a47449a4820435e87e87d715b8bf26415b8227 Mon Sep 17 00:00:00 2001 From: TL Date: Wed, 6 May 2026 11:18:59 +0800 Subject: [PATCH 2/3] refactor(envy): rename env.schema.ts to env.config.ts --- packages/envy/docs/setup.md | 10 +- packages/envy/src/commands/init.ts | 19 +++- packages/envy/src/helpers/index.ts | 50 +++++++--- .../{env.schema.ts => env.config.ts} | 0 packages/envy/tests/envy.test.ts | 99 ++++++++++++++++++- 5 files changed, 155 insertions(+), 23 deletions(-) rename packages/envy/templates/{env.schema.ts => env.config.ts} (100%) diff --git a/packages/envy/docs/setup.md b/packages/envy/docs/setup.md index 489bb70..b052a4d 100644 --- a/packages/envy/docs/setup.md +++ b/packages/envy/docs/setup.md @@ -72,14 +72,16 @@ Envy uses dotenv-compatible parsing and expansion under the hood. Variables can ### Validation Contract -Before Envy can validate env var values, the project needs a validation contract. The generated `env.schema.ts` file is where each supported application environment defines its allowed env keys and value constraints. +Before Envy can validate env var values, the project needs a validation contract. The generated `env.config.ts` file is where each supported application environment defines its allowed env keys and value constraints. + +> Note: Existing projects with `env.schema.ts` continue to work, but new projects should use `env.config.ts`. The mental model: before filling exact values into env files, define how those values should behave: - Which variables can each environment define? - What value constraints should each variable satisfy? -If an var is passed from the script of package.json, it should be defined in `env.schema.ts` too. +If an env var is passed from the script of package.json, it should be defined in `env.config.ts` too. ### Environment Variable Typings @@ -93,7 +95,7 @@ To generate them, run: npm run generate:env-types ``` -You typically do so when `env.schema.ts` changes. +You typically do so when `env.config.ts` changes. For details, see [The design of `env.d.ts`](./concepts.md#the-design-of-envdts). @@ -185,4 +187,4 @@ Since Envy asks for a small mental shift from Vite's default environment model, - Forget about `NODE_ENV` and Vite mode as application-environment selectors. Use `APP_ENV` for that job. - You decide which runtime loads env vars: build config, bundled app code, test setup, scripts, or any combination of them. -- From now on, define the contract in `env.schema.ts` first, then implement the values in the corresponding env var files. +- From now on, define the contract in `env.config.ts` first, then implement the values in the corresponding env var files. diff --git a/packages/envy/src/commands/init.ts b/packages/envy/src/commands/init.ts index ed3622d..49a07f0 100644 --- a/packages/envy/src/commands/init.ts +++ b/packages/envy/src/commands/init.ts @@ -19,8 +19,11 @@ const ENVY_SCRIPTS = { "generate:env-types": "envy generate-env-types", } as const; +const ENV_CONFIG_FILE_NAME = "env.config.ts"; +const LEGACY_ENV_SCHEMA_FILE_NAME = "env.schema.ts"; + const TEMPLATE_FILE_NAMES = [ - "env.schema.ts", + ENV_CONFIG_FILE_NAME, ".env.default", ".env", ".env.development.defaults", @@ -72,6 +75,14 @@ function copyTemplateIfMissing(cwd: string, templatesDirectory: string, fileName return `${created ? "created" : "kept existing"} ${fileName}`; } +function copyEnvConfigTemplateIfMissing(cwd: string, templatesDirectory: string): string { + if (existsSync(path.resolve(cwd, LEGACY_ENV_SCHEMA_FILE_NAME))) { + return `kept existing ${LEGACY_ENV_SCHEMA_FILE_NAME}`; + } + + return copyTemplateIfMissing(cwd, templatesDirectory, ENV_CONFIG_FILE_NAME); +} + function updatePackageJsonScripts(cwd: string): string { const packageJsonPath = path.resolve(cwd, "package.json"); @@ -111,7 +122,11 @@ export function initProject({ cwd = process.cwd() }: InitOptions = {}): InitResu messages.push(updatePackageJsonScripts(cwd)); for (const fileName of TEMPLATE_FILE_NAMES) { - messages.push(copyTemplateIfMissing(cwd, templatesDirectory, fileName)); + messages.push( + fileName === ENV_CONFIG_FILE_NAME + ? copyEnvConfigTemplateIfMissing(cwd, templatesDirectory) + : copyTemplateIfMissing(cwd, templatesDirectory, fileName), + ); } messages.push(updateGitignore(cwd)); diff --git a/packages/envy/src/helpers/index.ts b/packages/envy/src/helpers/index.ts index 90674af..afbe8e0 100644 --- a/packages/envy/src/helpers/index.ts +++ b/packages/envy/src/helpers/index.ts @@ -12,14 +12,15 @@ import type { } from "./types.ts"; import { isObject, renderZodError } from "./utils.ts"; -const SCHEMA_FILE_NAME = "env.schema.ts"; +const CONFIG_FILE_NAME = "env.config.ts"; +const LEGACY_SCHEMA_FILE_NAME = "env.schema.ts"; interface EnvFile { path: string; parsed: Record; } -interface SchemaModule { +interface EnvConfigModule { schemaByAppEnv?: unknown; } @@ -102,7 +103,7 @@ function isZodLikeSchema(value: unknown): boolean { function validateSchemaByAppEnv(value: unknown): SchemaByAppEnv { if (!isObject(value)) { - throw new Error("[envy] env.schema.ts must export an object named schemaByAppEnv."); + throw new Error("[envy] env.config.ts must export an object named schemaByAppEnv."); } const missingKeys = INTERNAL_APP_ENV_LIST.filter((appEnv) => !(appEnv in value)); @@ -135,30 +136,47 @@ function validateSchemaByAppEnv(value: unknown): SchemaByAppEnv { return value as SchemaByAppEnv; } -function importUserSchemaModule(schemaPath: string): SchemaModule { - const importedModule = tsxRequire(schemaPath, import.meta.url) as SchemaModule & { +function importUserEnvConfigModule(configPath: string): EnvConfigModule { + const importedModule = tsxRequire(configPath, import.meta.url) as EnvConfigModule & { default?: unknown; }; return ( isObject(importedModule.default) ? importedModule.default : importedModule - ) as SchemaModule; + ) as EnvConfigModule; } -function loadUserEnvSchema(cwd = process.cwd()): SchemaByAppEnv { - const schemaPath = path.resolve(cwd, SCHEMA_FILE_NAME); +function renderEnvConfigFileName(configPath: string): string { + return path.basename(configPath); +} - if (!existsSync(schemaPath)) { - throw new Error(`[envy] Missing ${SCHEMA_FILE_NAME} at project root: ${cwd}.`); +function resolveUserEnvConfigPath(cwd: string): string { + const configPath = path.resolve(cwd, CONFIG_FILE_NAME); + if (existsSync(configPath)) { + return configPath; } - const schemaModule = importUserSchemaModule(schemaPath); + const legacySchemaPath = path.resolve(cwd, LEGACY_SCHEMA_FILE_NAME); + if (existsSync(legacySchemaPath)) { + return legacySchemaPath; + } + + throw new Error( + `[envy] Missing ${CONFIG_FILE_NAME} at project root: ${cwd}. ` + + `${LEGACY_SCHEMA_FILE_NAME} is still supported for existing projects.`, + ); +} + +function loadUserEnvSchema(cwd = process.cwd()): SchemaByAppEnv { + const configPath = resolveUserEnvConfigPath(cwd); + + const envConfigModule = importUserEnvConfigModule(configPath); - if (!("schemaByAppEnv" in schemaModule)) { - throw new Error("[envy] env.schema.ts must export schemaByAppEnv."); + if (!("schemaByAppEnv" in envConfigModule)) { + throw new Error(`[envy] ${renderEnvConfigFileName(configPath)} must export schemaByAppEnv.`); } - return validateSchemaByAppEnv(schemaModule.schemaByAppEnv); + return validateSchemaByAppEnv(envConfigModule.schemaByAppEnv); } function assertStringEnvValues(env: Record): LoadedEnvVar { @@ -170,7 +188,7 @@ function assertStringEnvValues(env: Record): LoadedEnvVar { throw new Error( [ "[envy] Env schema output must contain only string values.", - "Env files are string-based; parse booleans/numbers at usage sites instead of transforming them in env.schema.ts.", + "Env files are string-based; parse booleans/numbers at usage sites instead of transforming them in env.config.ts.", `Non-string keys: ${nonStringKeys.join(", ")}.`, ].join("\n"), ); @@ -191,7 +209,7 @@ function validateProjectEnv(env: Record, cwd: string): LoadedEnv throw new Error( [ `[envy] Invalid env vars for APP_ENV='${appEnv}'.`, - "Update env.schema.ts when adding a new env var.", + "Update env.config.ts when adding a new env var.", renderZodError(result.error), ].join("\n"), ); diff --git a/packages/envy/templates/env.schema.ts b/packages/envy/templates/env.config.ts similarity index 100% rename from packages/envy/templates/env.schema.ts rename to packages/envy/templates/env.config.ts diff --git a/packages/envy/tests/envy.test.ts b/packages/envy/tests/envy.test.ts index 64fd483..c4a88f2 100644 --- a/packages/envy/tests/envy.test.ts +++ b/packages/envy/tests/envy.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, expect, test } from "vite-plus/test"; import { resolveProjectEnv, loadProjectEnv } from "../src/helpers/index.ts"; import { renderEnvTypeContent } from "../src/commands/generate-env-types/helpers.ts"; +import { initProject } from "../src/commands/init.ts"; import { defineEnvVarPlugin } from "../src/index.ts"; const tempProjectPaths: string[] = []; @@ -14,7 +15,7 @@ async function createTempProject(): Promise { tempProjectPaths.push(cwd); await writeFile( - path.join(cwd, "env.schema.ts"), + path.join(cwd, "env.config.ts"), `import { z, type SchemaByAppEnv } from "${publicIndexUrl}"; const requiredString = z.string().min(1); @@ -65,6 +66,22 @@ export const schemaByAppEnv = { return cwd; } +async function createEmptyTempProject(): Promise { + const cwd = await mkdtemp(path.join(tmpdir(), "envy-test-")); + tempProjectPaths.push(cwd); + + return cwd; +} + +async function fileExists(filePath: string): Promise { + try { + await readFile(filePath); + return true; + } catch { + return false; + } +} + afterEach(async () => { await Promise.all( tempProjectPaths.splice(0).map((cwd) => rm(cwd, { recursive: true, force: true })), @@ -163,6 +180,86 @@ test("throws on unknown env vars when the user schema is strict", async () => { expect(() => resolveProjectEnv({ cwd, appEnv: "staging" })).toThrow("UNKNOWN_KEY"); }); +test("supports legacy env.schema.ts projects", async () => { + const cwd = await createTempProject(); + await rm(path.join(cwd, "env.config.ts")); + await writeFile( + path.join(cwd, "env.schema.ts"), + `import { z, type SchemaByAppEnv } from "${publicIndexUrl}"; + +const optionalString = z.string().optional(); + +export const schemaByAppEnv = { + development: z.object({ + APP_ENV: z.literal("development"), + PROJECT_NAME: z.string().min(1), + ENV_EXPANSION_TEST: optionalString, + ENV_PRIORITY_TEST: optionalString, + }).strict(), + staging: z.object({ + APP_ENV: z.literal("staging"), + PROJECT_NAME: z.string().min(1), + ENV_EXPANSION_TEST: optionalString, + ENV_PRIORITY_TEST: optionalString, + }).strict(), + production: z.object({ + APP_ENV: z.literal("production"), + PROJECT_NAME: z.string().min(1), + ENV_EXPANSION_TEST: optionalString, + ENV_PRIORITY_TEST: optionalString, + }).strict(), +} satisfies SchemaByAppEnv; +`, + ); + + const env = resolveProjectEnv({ cwd, appEnv: "development" }); + + expect(env).toMatchObject({ + APP_ENV: "development", + PROJECT_NAME: "rightdown", + }); +}); + +test("init preserves legacy env.schema.ts without creating env.config.ts", async () => { + const cwd = await createEmptyTempProject(); + await writeFile(path.join(cwd, "package.json"), '{"scripts":{}}\n'); + await writeFile( + path.join(cwd, "env.schema.ts"), + `import { z, type SchemaByAppEnv } from "${publicIndexUrl}"; + +const optionalString = z.string().optional(); + +export const schemaByAppEnv = { + development: z.object({ + APP_ENV: z.literal("development"), + PROJECT_NAME: z.string().min(1), + APP_CHANNEL_LABEL: optionalString, + DEVELOPMENT_DEFAULT_ONLY: optionalString, + }).strict(), + staging: z.object({ + APP_ENV: z.literal("staging"), + PROJECT_NAME: z.string().min(1), + APP_CHANNEL_LABEL: optionalString, + STAGING_DEFAULT_ONLY: optionalString, + }).strict(), + production: z.object({ + APP_ENV: z.literal("production"), + PROJECT_NAME: z.string().min(1), + APP_CHANNEL_LABEL: optionalString, + PRODUCTION_DEFAULT_ONLY: optionalString, + }).strict(), +} satisfies SchemaByAppEnv; +`, + ); + + const result = initProject({ cwd }); + + expect(result.messages).toContain("kept existing env.schema.ts"); + expect(await fileExists(path.join(cwd, "env.schema.ts"))).toBe(true); + expect(await fileExists(path.join(cwd, "env.config.ts"))).toBe(false); + expect(await fileExists(path.join(cwd, "env.d.ts"))).toBe(true); +}); + test("generated env type content can be written to env.d.ts", async () => { const cwd = await createTempProject(); const { generateEnvTypes } = await import("../src/commands/generate-env-types/index.ts"); From 29794a7dec741032fe1482a68e8cc6a39e5b4288 Mon Sep 17 00:00:00 2001 From: TL Date: Wed, 6 May 2026 11:20:09 +0800 Subject: [PATCH 3/3] feat(system): auto publish Envy to NPM once it's released on Github --- .github/workflows/release-please.yml | 34 +++++++++++++++++++++- scripts/generate-release-please-configs.ts | 34 +++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 402b10f..b1b2d92 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -8,12 +8,44 @@ on: permissions: contents: write pull-requests: write + id-token: write # Required for npm Trusted Publishing / OIDC. jobs: release-please: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@v5 + - name: Create or update GitHub releases + id: release + uses: googleapis/release-please-action@v5 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + + - name: Checkout release commit + if: ${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + uses: actions/checkout@v5 + + - name: Setup Vite+ and Node.js + if: ${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + + # Trusted Publishing requires npm 11.5.1+ and a supported Node.js runtime. + # Keep npm current so publish authentication does not fail due to an old bundled npm. + - name: Update npm CLI + if: ${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + run: npm install -g npm@latest + + # Do not set NODE_AUTH_TOKEN here. npm authenticates through the Trusted Publisher + # connection configured on npmjs.com for @developwise/envy. + - name: Install dependencies + if: ${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + run: vp install --frozen-lockfile + + # Trusted Publishing generates npm provenance automatically; --provenance is unnecessary. + - name: Publish @developwise/envy to npm + if: ${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + working-directory: packages/envy + run: npm publish --access public diff --git a/scripts/generate-release-please-configs.ts b/scripts/generate-release-please-configs.ts index 83f71dd..31821dc 100644 --- a/scripts/generate-release-please-configs.ts +++ b/scripts/generate-release-please-configs.ts @@ -55,15 +55,47 @@ on: permissions: contents: write pull-requests: write + id-token: write # Required for npm Trusted Publishing / OIDC. jobs: release-please: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@v5 + - name: Create or update GitHub releases + id: release + uses: googleapis/release-please-action@v5 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + + - name: Checkout release commit + if: \${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + uses: actions/checkout@v5 + + - name: Setup Vite+ and Node.js + if: \${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + + # Trusted Publishing requires npm 11.5.1+ and a supported Node.js runtime. + # Keep npm current so publish authentication does not fail due to an old bundled npm. + - name: Update npm CLI + if: \${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + run: npm install -g npm@latest + + # Do not set NODE_AUTH_TOKEN here. npm authenticates through the Trusted Publisher + # connection configured on npmjs.com for @developwise/envy. + - name: Install dependencies + if: \${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + run: vp install --frozen-lockfile + + # Trusted Publishing generates npm provenance automatically; --provenance is unnecessary. + - name: Publish @developwise/envy to npm + if: \${{ steps.release.outputs['packages/envy--release_created'] == 'true' }} + working-directory: packages/envy + run: npm publish --access public `; }