Skip to content

Commit 4eda205

Browse files
committed
Update contributing guidelines, enhance CLI commands with new env options, and improve error handling in doctor/check commands. Bump package versions to 0.1.2 and add dotenv dependency.
1 parent 1471f68 commit 4eda205

20 files changed

Lines changed: 597 additions & 61 deletions

.npmrc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Public registry (explicit; same as npm default).
2+
registry=https://registry.npmjs.org/
3+
4+
# Do not add //registry.npmjs.org/:_authToken=... here — never commit tokens.
5+
# For local publish without OTP, use your user-level npmrc, for example:
6+
# Windows: %USERPROFILE%\.npmrc
7+
# macOS/Linux: ~/.npmrc
8+
# with a single line (Automation granular token from npmjs.com):
9+
# //registry.npmjs.org/:_authToken=npm_yourTokenHere
10+
# See CONTRIBUTING.md

CONTRIBUTING.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ pnpm test
2424
3. Add or update tests when behavior or public API changes.
2525
4. Ensure `pnpm build` and `pnpm test` pass locally.
2626

27+
### Pre-release check (maintainers)
28+
29+
Before pushing a release or opening the version PR:
30+
31+
1. `pnpm install`
32+
2. `pnpm build` (or `pnpm run build` from repo root)
33+
3. `pnpm test`
34+
4. From `packages/cli`, run `pnpm pack` and confirm `package/package.json` in the tarball lists a real semver for `@envra/core` (not `workspace:*`).
35+
2736
## Publishing (maintainers)
2837

2938
We use [Changesets](https://github.com/changesets/changesets) for versioning and npm releases of `@envra/*`.
@@ -82,6 +91,37 @@ pnpm exec changeset publish
8291

8392
Prefer the GitHub Action so versions and git tags stay aligned with changelogs.
8493

94+
### Troubleshooting local `changeset publish`
95+
96+
**`warn Received 404` for `npm info "@envra/..."`**
97+
98+
Often normal: Changesets checks whether the **new** version (e.g. `0.1.1`) is already on the registry; until it is published, that can return 404. Your previous release (e.g. `0.1.0`) can still be live — verify with:
99+
100+
```bash
101+
npm view @envra/core version
102+
```
103+
104+
**`packages failed to publish` with no npm error**
105+
106+
Changesets does not always print npm’s stderr. Run one package to see the real code:
107+
108+
```bash
109+
pnpm build
110+
pnpm --filter @envra/core publish --access public --no-git-checks
111+
```
112+
113+
**`npm error code EOTP` (most common after 0.1.0 works)**
114+
115+
Your npm account uses **2FA for publishing**. Non-interactive `changeset publish` cannot prompt for an OTP.
116+
117+
- **Quick:** publish with a fresh code from your authenticator:
118+
`pnpm --filter @envra/core publish --access public --no-git-checks --otp=123456`
119+
(repeat for other packages in order: core → cli, next, eslint-plugin — or use an Automation token below and run `pnpm exec changeset publish` once.)
120+
- **Better for repeated CLI publishes:** create an [**Automation**](https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-granular-access-tokens-on-the-website) granular access token (publish-capable, no OTP), then `npm login` or set in user `.npmrc`:
121+
`//registry.npmjs.org/:_authToken=npm_yourTokenHere`
122+
123+
GitHub Actions releases use **Trusted Publishing (OIDC)** and do not need this OTP when OIDC is configured on each package.
124+
85125
### Troubleshooting OIDC
86126

87127
- `ENEEDAUTH`: workflow file name on npm must match `.github/workflows/release.yml` exactly; repo slug must match; use **GitHub-hosted** runners.

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,17 @@ pnpm envra doctor -c ./env.config.ts
9393

9494
Loads TypeScript configs via **jiti** (no separate compile step for the config file). Your app should depend on `@envra/core`; add `@envra/cli` as a dev dependency.
9595

96+
Export `defineEnv` as `default` / `env`, or export field builders as `schema`, `environmentFields`, `envraSchema`, or `envFields`.
97+
98+
**`check` / `doctor`** merge env in this order: start from `process.env`, then each `--env-file` (repeatable), then optional `--env-dir` loads `<dir>/.env` and `<dir>/.env.<node-env>`. Use `--env-preset nest` to default `--env-dir` to `env` (common Nest layout). `--node-env` overrides the segment for `.env.<name>` (default: `NODE_ENV` or `development`). **`--profile`** is the **schema** profile (requiredIn / onlyIn), not the file name.
99+
100+
**`doctor --undeclared`**: `ignore-system` (default, skips noisy OS/npm/editor keys), `all`, or `loaded-only` (only keys that came from loaded files).
101+
102+
**`--json`** on `check` / `doctor` prints machine-readable output for CI.
103+
96104
| Command | Use case |
97105
| --------- | --------------------------------- |
98-
| `check` | CI / preflight — validate `process.env` |
106+
| `check` | CI / preflight — validate merged env |
99107
| `sync` | Regenerate `.env.example` |
100108
| `docs` | Regenerate `ENVIRONMENT.md` |
101109
| `doctor` | Undeclared vars, typos, deprecations, profile rules |
@@ -131,7 +139,7 @@ Treat this as directional; versions and features change over time.
131139

132140
- **Node**: [examples/node-basic](examples/node-basic)
133141
- **Next.js App Router**: [examples/next-app-router](examples/next-app-router)
134-
- **NestJS**: [examples/nestjs](examples/nestjs)`defineEnv` with `options.source` from `ConfigModule.validate`
142+
- **NestJS**: [examples/nestjs](examples/nestjs)`defineEnv` with `unknownRecordToEnvSource(config)` in `ConfigModule.validate`
135143

136144
---
137145

@@ -177,8 +185,6 @@ envra/
177185

178186
- Optional Zod bridge package
179187
- More framework entrypoints
180-
- Explicit dotenv file as a source (alongside `process.env`)
181-
- Richer `doctor` policies
182188

183189
---
184190

examples/nestjs/env.config.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineEnv, str, oneOf } from '@envra/core'
1+
import { defineEnv, str, unknownRecordToEnvSource, oneOf } from '@envra/core'
22

33
/** Field builders only — use in ConfigModule.validate after merging env files */
44
export const envraSchema = {
@@ -8,15 +8,12 @@ export const envraSchema = {
88

99
/**
1010
* Nest `ConfigModule.forRoot({ validate })` receives a plain object.
11-
* Pass it as `source` to defineEnv:
11+
* Use `unknownRecordToEnvSource` so numbers/objects are not coerced to `[object Object]`.
1212
*/
1313
export function validateNestConfig(config: Record<string, unknown>) {
14-
const flat: Record<string, string | undefined> = {}
15-
for (const [k, v] of Object.entries(config))
16-
flat[k] = v === undefined || v === null ? undefined : String(v)
17-
14+
const source = unknownRecordToEnvSource(config)
1815
return defineEnv(envraSchema, {
19-
source: flat,
20-
profile: flat.NODE_ENV ?? 'development',
16+
source,
17+
profile: source.NODE_ENV ?? 'development',
2118
}).values
2219
}

packages/cli/README.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @envra/cli
22

3-
Command-line tools for [envra](https://github.com/hasansmadix/envra): validate `process.env`, generate `.env.example` and `ENVIRONMENT.md`, and run hygiene checks (`doctor`).
3+
Command-line tools for [envra](https://github.com/hasansmadix/envra): validate env, generate `.env.example` and `ENVIRONMENT.md`, and run hygiene checks (`doctor`).
44

55
## Install
66

@@ -21,7 +21,25 @@ pnpm envra docs -c ./env.config.ts -o ENVIRONMENT.md
2121
pnpm envra doctor -c ./env.config.ts
2222
```
2323

24-
Export `defineEnv(...)` as `default` or `env`, or export `envraSchema` / `schema` as field builders so the CLI can read the schema.
24+
Export `defineEnv(...)` as `default` or `env`, or export field builders as `envraSchema`, `schema`, `environmentFields`, or `envFields`.
25+
26+
## `check` and `doctor` — env loading
27+
28+
- **`--env-file <path>`** — repeatable; each file is parsed with [dotenv](https://github.com/motdotla/dotenv) and merged (later overrides earlier). Starts from `process.env`.
29+
- **`--env-dir <dir>`** — after `--env-file`, loads `<dir>/.env` then `<dir>/.env.<node-env>` if they exist.
30+
- **`--node-env <name>`** — segment for `.env.<name>` (default: `NODE_ENV` or `development`).
31+
- **`--env-preset nest`** — sets default `--env-dir` to `env` (matches many Nest `ConfigModule` layouts).
32+
33+
`--profile` / `-p` is the **schema** profile for envra rules (`requiredIn` / `onlyIn`), not the env file name.
34+
35+
## `doctor`
36+
37+
- **`--undeclared <policy>`**`ignore-system` (default), `all`, or `loaded-only` (only warn on extra keys that appeared in loaded files).
38+
- **`--json`** — print structured JSON for CI.
39+
40+
## Windows
41+
42+
If `envra` fails when run via `node` on Windows, call the entry file directly, e.g. `node node_modules/@envra/cli/dist/cli.js check -c ./env.config.ts`.
2543

2644
## Documentation
2745

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@envra/cli",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "CLI for envra: check, sync, docs, doctor",
55
"keywords": [
66
"env",
@@ -39,6 +39,7 @@
3939
"dependencies": {
4040
"@envra/core": "workspace:*",
4141
"commander": "^12.1.0",
42+
"dotenv": "^17.2.3",
4243
"jiti": "^2.4.2"
4344
},
4445
"devDependencies": {

packages/cli/src/cli.ts

Lines changed: 111 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,103 @@
1-
import { Command } from 'commander'
1+
import { readFileSync } from 'node:fs'
2+
import { dirname, join } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import { Command, Option } from 'commander'
5+
import type { DoctorUndeclaredPolicy } from '@envra/core'
26
import { loadConfigModule } from './loaders/load-config'
37
import { extractSchemaFromModule } from './loaders/extract-schema'
8+
import { mergeProcessEnvWithDotenvFiles } from './loaders/load-dotenv-merge'
49
import { runCheck } from './commands/check'
510
import { runSync } from './commands/sync'
611
import { runDocs } from './commands/docs'
712
import { runDoctorCmd } from './commands/doctor'
813
import { env as processEnv } from 'node:process'
14+
import { cwd } from 'node:process'
15+
16+
const __dirname = dirname(fileURLToPath(import.meta.url))
17+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string }
918

1019
function resolveProfile(profile: string | undefined): string {
11-
return (
12-
profile ??
13-
processEnv.NODE_ENV?.trim() ??
14-
'development'
15-
)
20+
return profile ?? processEnv.NODE_ENV?.trim() ?? 'development'
1621
}
1722

1823
function loadSchema(config: string) {
1924
const mod = loadConfigModule(config)
2025
const schema = extractSchemaFromModule(mod)
2126
if (!schema) {
2227
console.error(
23-
'Could not find env schema. Export defineEnv() result as default or named `env`, or export `envraSchema` / `schema` as field builders.',
28+
'Could not find env schema. Export defineEnv() result as default or named `env`, or export field builders as `envraSchema` / `schema` / `environmentFields` / `envFields`.',
2429
)
2530
process.exit(2)
2631
}
2732
return schema
2833
}
2934

35+
function collectEnvFile(value: string, previous: string[]) {
36+
return [...previous, value]
37+
}
38+
39+
function buildMergedEnv(opts: {
40+
envFile: string[]
41+
envDir?: string
42+
nodeEnv?: string
43+
envPresetNest?: boolean
44+
}) {
45+
return mergeProcessEnvWithDotenvFiles({
46+
cwd: cwd(),
47+
envFiles: opts.envFile,
48+
envDir: opts.envDir,
49+
nodeEnv: opts.nodeEnv,
50+
nestPreset: opts.envPresetNest,
51+
})
52+
}
53+
54+
function envFileOption() {
55+
return new Option('--env-file <path>', 'Load a .env file (repeatable; later overrides earlier)').argParser(
56+
collectEnvFile,
57+
)
58+
}
59+
60+
function sharedEnvOptions(cmd: Command) {
61+
return cmd
62+
.addOption(envFileOption().default([]))
63+
.addOption(new Option('--env-dir <dir>', 'Load <dir>/.env then <dir>/.env.<node-env>'))
64+
.addOption(
65+
new Option('--node-env <name>', 'Segment for .env.<name> (default: NODE_ENV or development)'),
66+
)
67+
.addOption(new Option('--env-preset <name>', 'Layout: nest → default --env-dir env').choices(['nest']))
68+
}
69+
3070
const program = new Command()
31-
program.name('envra').description('envra — typed environment tooling').version('0.1.0')
71+
program.name('envra').description('envra — typed environment tooling').version(pkg.version)
3272

33-
program
34-
.command('check')
35-
.description('Validate current process.env against schema')
36-
.requiredOption('-c, --config <path>', 'Path to env config module (e.g. ./env.config.ts)')
37-
.option('-p, --profile <name>', 'Profile (defaults to NODE_ENV or development)')
38-
.action((opts: { config: string; profile?: string }) => {
73+
sharedEnvOptions(
74+
program
75+
.command('check')
76+
.description('Validate merged env against schema')
77+
.requiredOption('-c, --config <path>', 'Path to env config module (e.g. ./env.config.ts)')
78+
.option('-p, --profile <name>', 'Schema profile (defaults to NODE_ENV or development)')
79+
.option('--json', 'Print JSON result to stdout', false),
80+
).action(
81+
(opts: {
82+
config: string
83+
profile?: string
84+
envFile: string[]
85+
envDir?: string
86+
nodeEnv?: string
87+
envPreset?: 'nest'
88+
json: boolean
89+
}) => {
3990
const schema = loadSchema(opts.config)
40-
const code = runCheck(schema, resolveProfile(opts.profile))
91+
const { env } = buildMergedEnv({
92+
envFile: opts.envFile ?? [],
93+
envDir: opts.envDir,
94+
nodeEnv: opts.nodeEnv,
95+
envPresetNest: opts.envPreset === 'nest',
96+
})
97+
const code = runCheck(schema, resolveProfile(opts.profile), env, opts.json)
4198
process.exit(code)
42-
})
99+
},
100+
)
43101

44102
program
45103
.command('sync')
@@ -61,15 +119,44 @@ program
61119
runDocs(schema, opts.out)
62120
})
63121

64-
program
65-
.command('doctor')
66-
.description('Hygiene: validation, undeclared vars, deprecations, typos')
67-
.requiredOption('-c, --config <path>', 'Path to env config module')
68-
.option('-p, --profile <name>', 'Profile (defaults to NODE_ENV or development)')
69-
.action((opts: { config: string; profile?: string }) => {
122+
sharedEnvOptions(
123+
program
124+
.command('doctor')
125+
.description('Hygiene: validation, undeclared vars, deprecations, typos')
126+
.requiredOption('-c, --config <path>', 'Path to env config module')
127+
.option('-p, --profile <name>', 'Schema profile (defaults to NODE_ENV or development)')
128+
.addOption(
129+
new Option('--undeclared <policy>', 'How to treat env keys outside the schema')
130+
.choices(['all', 'ignore-system', 'loaded-only'])
131+
.default('ignore-system'),
132+
)
133+
.option('--json', 'Print JSON result to stdout', false),
134+
).action(
135+
(opts: {
136+
config: string
137+
profile?: string
138+
undeclared: DoctorUndeclaredPolicy
139+
envFile: string[]
140+
envDir?: string
141+
nodeEnv?: string
142+
envPreset?: 'nest'
143+
json: boolean
144+
}) => {
70145
const schema = loadSchema(opts.config)
71-
const code = runDoctorCmd(schema, resolveProfile(opts.profile))
146+
const { env, loadedKeys } = buildMergedEnv({
147+
envFile: opts.envFile ?? [],
148+
envDir: opts.envDir,
149+
nodeEnv: opts.nodeEnv,
150+
envPresetNest: opts.envPreset === 'nest',
151+
})
152+
const code = runDoctorCmd(schema, resolveProfile(opts.profile), {
153+
env,
154+
undeclared: opts.undeclared,
155+
loadedEnvKeys: opts.undeclared === 'loaded-only' ? loadedKeys : undefined,
156+
json: opts.json,
157+
})
72158
process.exit(code)
73-
})
159+
},
160+
)
74161

75162
program.parse()

packages/cli/src/commands/check.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { EnvSchema } from '@envra/core'
22
import type { ValidationIssue } from '@envra/core'
33
import { recordToEnvSource, validateSchema } from '@envra/core'
4-
import { env as processEnv } from 'node:process'
54

65
function issuesByKey(issues: ValidationIssue[]): Map<string, ValidationIssue[]> {
76
const m = new Map<string, ValidationIssue[]>()
@@ -13,12 +12,33 @@ function issuesByKey(issues: ValidationIssue[]): Map<string, ValidationIssue[]>
1312
return m
1413
}
1514

16-
export function runCheck(schema: EnvSchema, profile: string): number {
17-
const source = recordToEnvSource(processEnv as Record<string, string | undefined>)
15+
export function runCheck(
16+
schema: EnvSchema,
17+
profile: string,
18+
env: Record<string, string | undefined>,
19+
json: boolean,
20+
): number {
21+
const source = recordToEnvSource(env)
1822
const { issues } = validateSchema(schema, source, profile)
1923
const byKey = issuesByKey(issues)
2024

21-
for (const key of Object.keys(schema)) {
25+
const schemaKeys = Object.keys(schema)
26+
if (json) {
27+
const keyResults = schemaKeys.map((key) => {
28+
const list = byKey.get(key) ?? []
29+
return { key, ok: list.length === 0, issues: list }
30+
})
31+
const out = {
32+
command: 'check' as const,
33+
profile,
34+
issueCount: issues.length,
35+
keyResults,
36+
}
37+
console.log(JSON.stringify(out, null, 2))
38+
return issues.length ? 1 : 0
39+
}
40+
41+
for (const key of schemaKeys) {
2242
const list = byKey.get(key) ?? []
2343
if (list.length) {
2444
console.error(`✖ ${key}`)

0 commit comments

Comments
 (0)