Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 2 additions & 5 deletions packages/envy/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
10 changes: 6 additions & 4 deletions packages/envy/docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).

Expand Down Expand Up @@ -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.
19 changes: 17 additions & 2 deletions packages/envy/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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));
Expand Down
50 changes: 34 additions & 16 deletions packages/envy/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

interface SchemaModule {
interface EnvConfigModule {
schemaByAppEnv?: unknown;
}

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<string, unknown>): LoadedEnvVar {
Expand All @@ -170,7 +188,7 @@ function assertStringEnvValues(env: Record<string, unknown>): 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"),
);
Expand All @@ -191,7 +209,7 @@ function validateProjectEnv(env: Record<string, string>, 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"),
);
Expand Down
99 changes: 98 additions & 1 deletion packages/envy/tests/envy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -14,7 +15,7 @@ async function createTempProject(): Promise<string> {
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);
Expand Down Expand Up @@ -65,6 +66,22 @@ export const schemaByAppEnv = {
return cwd;
}

async function createEmptyTempProject(): Promise<string> {
const cwd = await mkdtemp(path.join(tmpdir(), "envy-test-"));
tempProjectPaths.push(cwd);

return cwd;
}

async function fileExists(filePath: string): Promise<boolean> {
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 })),
Expand Down Expand Up @@ -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");
Expand Down
Loading