hazo_env
v0.10.1
Published
Canonical environment resolver — typed env names, per-env DB/file/secret config, doctor and CLI for hazo apps
Maintainers
Readme
hazo_env
Canonical environment resolver for hazo apps. Typed env names, per-env DB/file/secret config, a local Postgres/files sync engine (envsync), a doctor diagnostic command, and a hazo-env CLI.
What it does
- Env resolution — typed
HazoEnv(dev | test | staging | prod), role mapping, pattern declaration, andassertEnv()to fail fast at boot ifHAZO_ENVis invalid. - Per-env DB config —
resolveConnectConfig()maps the current env to itshazo_connectconfig (SQLite or PostgREST) with zero hardcoded connection strings in app code. - Per-env file config —
resolveFilesConfig()maps the current env to ahazo_filesconfig rooted at the declareddata_root. - Secrets —
getSecret()resolves from.env.localonly; placeholders inhazo_env_config.iniare substituted at runtime without storing secrets. envsync— local Postgres/files sync engine (hazo-env sync/hazo-env serve). Dumps/restores a database viapg_dump/pg_restoreand archives/restores a files root viatar, entirely on one host — no SSH, no PostgREST row-copy. See "envsync" below.- Doctor —
doctor()/hazo-env doctorvalidates pattern, DB reachability, required secrets (no values printed), data_root writability, schema level, and (with--probe --all) migration parity across envs. - CLI —
hazo-env current | doctor | sync | serve.
Installation
npm install hazo_envPeer deps (required): hazo_core, hazo_config.
Peer deps (optional): hazo_connect (for resolveConnectConfig), hazo_files (for resolveFilesConfig).
Quick start
1. Create hazo_env_config.ini (copy from config/hazo_env_config.ini.sample)
[env]
pattern = dev, prod
app = myapp
[db.dev]
type = sqlite
database_path = ${DATA_ROOT}/dev.sqlite
[db.prod]
type = postgrest
base_url = ${POSTGREST_PROD_URL}
api_key = ${POSTGREST_API_KEY}2. Create .env.local (gitignored, one per deployment)
POSTGREST_PROD_URL=https://db.myapp.com
POSTGREST_API_KEY=...3. Boot
import { assertEnv } from 'hazo_env';
import { resolveConnectConfig } from 'hazo_env';
import { createHazoConnect } from 'hazo_connect';
assertEnv(); // throws if HAZO_ENV not in pattern
const adapter = await createHazoConnect(resolveConnectConfig());API
Env resolution (client-safe — also in hazo_env/client)
import { getEnv, getEnvRole, getRoleMap, getPattern, listEnvs, assertEnv, describeEnv,
isDev, isTest, isStaging, isProd } from 'hazo_env';
getEnv() // 'dev' | 'test' | 'staging' | 'prod'
getEnvRole() // 'development' | 'test' | 'staging' | 'production'
getRoleMap() // EnvRoleMap — full env→role mapping (config-driven; safe defaults)
getPattern() // e.g. 'dev_prod'
listEnvs() // ['dev', 'prod'] — valid envs for the declared pattern
assertEnv() // throws HazoError(ENV_INVALID) if HAZO_ENV is outside the pattern
describeEnv() // { env, role, pattern, app, dataRoot, hostHint, roles? }
isDev() / isTest() / isStaging() / isProd()getEnvRole() uses a config-driven role map so custom env names (e.g. preview, qa) resolve to the correct broad role. Unknown names default to 'development' — never silently treated as production. Configure custom mappings in [env.roles]:
[env.roles]
preview = staging
qa = testConfiguring environment roles
Canonical env names (dev, test, staging, prod) are pre-mapped and require no configuration. Non-canonical names (anything else) must declare their role in the [env.roles] INI section, or doctor will flag an error. Unknown names without a declaration fall back to 'development' (safe: no masking, no prod-protection).
[env.roles]
; Map arbitrary env names to the four fixed roles:
; development | test | staging | production
qa = test
live = production
rc = stagingValid role values: development, test, staging, production.
Client-side global injection
On the client (browser), the INI file is not available. Inject the resolved values at render time using globalThis globals — the same pattern used for the env pattern:
// In your Next.js layout server component or _app (server-side):
// globalThis.__HAZO_ENV_PATTERN__ = 'dev,prod'; // comma-separated pattern
// globalThis.__HAZO_ENV_ROLES__ = { live: 'production', qa: 'test' };
// Client bundle reads these automatically:
import { getPattern, getRoleMap } from 'hazo_env/client';
getPattern() // reads __HAZO_ENV_PATTERN__
getRoleMap() // merges DEFAULT_ROLE_MAP → __HAZO_ENV_ROLES__Both globals are optional; omitting them makes the client fall back to canonical defaults.
Utility
import { normalize } from 'hazo_env';
normalize(' Dev ') // → 'dev' (trim + lowercase)Resolvers (server-only)
import { resolveConnectConfig } from 'hazo_env';
import { resolveFilesConfig } from 'hazo_env';
import { getSecret } from 'hazo_env';
// Returns the hazo_connect config for the current env
resolveConnectConfig()
// Returns the hazo_files config for the current env (base_path from data_root)
resolveFilesConfig()
// Read a value from .env.local — throws HazoError(SECRET_MISSING) when required + absent
getSecret('POSTGREST_API_KEY', { required: true })Doctor
import { doctor } from 'hazo_env';
const report = await doctor({ probe: true, all: true });
// report.passed boolean
// report.checks DoctorCheck[] — { label, status: 'ok'|'warn'|'error', detail? }
// Migration-readiness checks (--probe --all): _migrations parity across envs,
// masking ruleset column validation against live schema.envsync — local Postgres/files sync (server/ops-only)
envsync copies a database or files root between environments that live on the same host (or where you're willing to move a dump/archive by hand) using plain pg_dump/pg_restore/tar — no SSH, no PostgREST row-copy loop, no masking pass. It's driven via the CLI or an optional local HTTP service; the engine functions (downloadDb, uploadDb, downloadFiles, uploadFiles) live at hazo_env/dist/envsync/engine.js and are intentionally not re-exported from the package's main index.ts — reach them through that subpath or through the CLI.
# Config: [envsync] section in hazo_env_config.ini — source_db, target_db, files_root, work_dir,
# keep, prod_db_names, prod_db_pattern, allow_prod
hazo-env sync download-db # pg_dump source_db → work_dir
hazo-env sync upload-db --dump <path> --confirm # pg_restore into target_db (--allow-prod-target for a prod target)
hazo-env sync download-files # tar files_root (+ current .env.local) → work_dir
hazo-env sync upload-files --archive <path> # dry-run: prints the .env.local diff it would apply
hazo-env sync upload-files --archive <path> --confirm # actually restores files + merges env overrides
hazo-env sync upload-files --archive <path> --confirm --skip-env # restore files only; leave .env.local untouched
hazo-env sync upload-files --archive <path> --confirm --prune-env # also DELETE .env.local keys the archive lacks
hazo-env sync upload-files --archive <path> --show-values # print env values in the diff (hidden by default)
hazo-env serve [--port <n>] [--bind <host>] # HTTP service over the same engine — requires HAZO_ENVSYNC_TOKENSafety model (see CHANGE_LOG.md 0.7.0 for the original design and 0.10.0 for the hardening pass):
- Single writer. Every destructive op runs under
withLock. - Validate, then destroy. The dump/archive is fully checked (exists, non-empty, inside
work_dir, and — for archives — lists cleanly undertar -t) beforedropdbor thefiles_rootwipe runs. A typo'd path can never delete anything. - Confined paths.
--dump/--archivemust resolve (symlinks included) insidework_dir, must not start with-, and are passed after a--end-of-options separator. - Production guard. A target is refused if it is on
prod_db_namesor matchesprod_db_pattern(default: aprod/production/livetoken anywhere in the name). Override with--allow-prod-targeton the CLI, orallow_prod = truein[envsync]. The HTTP service reads the override only from config — a request body can never set it. .env.localkeys are preserved. Keys that exist only in the target survive a restore by default; deleting them requires--prune-env/pruneEnv: true. The diff preview lists every key with anadded/changed/unchanged/removedstatus.- No secrets in output. Connection-string credentials are stripped from every progress line, and env diffs are reported as key + status — the HTTP API and control page never carry
.env.localvalues.
CLI
hazo-env current # prints env, role, pattern, app, data_root
hazo-env doctor [--env <e>] [--all] # red/green validation table
hazo-env sync download-db | upload-db --dump <p> [--confirm] [--allow-prod-target]
hazo-env sync download-files | upload-files --archive <p> [--confirm] [--allow-prod-target]
[--skip-env] [--prune-env] [--show-values]
hazo-env serve [--port <n>] [--bind <host>] # requires HAZO_ENVSYNC_TOKENConfig reference (hazo_env_config.ini)
See config/hazo_env_config.ini.sample for the full annotated template.
Key sections:
[env]—pattern(comma-separated env names),app(app identifier)[data]—root(data_root; defaultapp_data)[db.<env>]—type(sqlite|postgrest),database_path/base_url/api_key[host.<env>]—location(local|remote)
Secret placeholders use ${ENV_VAR_NAME} syntax — hazo_env substitutes them from .env.local at runtime.
For envsync's own [envsync] / [migrate.env_overrides] config sections, see the envsync section above and CHANGE_LOG.md (0.7.0 entry).
License
MIT
