npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

composable.env

v0.6.0

Published

Composable environment management: build .env files for every service from reusable components, profiles, and contracts.

Downloads

1,384

Readme

composable.env

Build .env files for every service from reusable components, profiles, and contracts.

Like CSS for environment variables. Define once, compose everywhere, validate against contracts. Encrypt secrets inline — no external service needed.

npm install composable.env

Table of contents


Install

# Global (recommended for standalone use)
npm install -g composable.env

# Local dev dependency (recommended for monorepos)
npm install -D composable.env

Requires Node.js 18+. The CLI command is ce (alias: cenv).

Vault (optional)

If you want to encrypt secrets in .env.shared, install the vault dependencies:

npm install age-encryption sops-age @noble/curves @scure/base

The vault is completely optional — composable.env works fully without it. See Vault — encrypted secrets for details.


Quick start

1. Scaffold the project

ce init --examples

This creates the env/ directory structure with example components, profiles, contracts, and shared values.

2. See what was created

env/
  components/
    database.env          # Database variables by environment
    redis.env             # Redis variables by environment
  profiles/
    default.json          # Lists all components (required)
    production.json       # Production overrides
    staging.json          # Staging — extends production
  contracts/
    api.contract.json     # What the API service needs
  .env.shared             # Team values (committed)
  .env.local              # Personal overrides (gitignored)

3. Build environment files

# Build for local development (default profile)
ce build

# Build for production
ce build --profile production

# Build for staging
ce build --profile staging

Each contract generates a .ce.{profile} file at the service's location:

apps/api/.ce.production
apps/web/.ce.production
apps/worker/.ce.production

4. Run a command with the environment loaded

ce run -- npm start

# With a specific profile
ce run --profile staging -- npm start

# Profile as trailing arg (auto-detected)
ce run -- npm start production

If the .ce.* file doesn't exist yet, ce run auto-builds it.


Core concepts

Components

INI files with named sections. Each section maps to a profile name.

# env/components/database.env
NAMESPACE=DATABASE

[default]
HOST=localhost
PORT=5432
NAME=myapp_dev
USER=postgres
PASSWORD=postgres

[production]
HOST=${DATABASE_PROD_HOST}
PORT=5432
NAME=myapp
USER=${DATABASE_PROD_USER}
PASSWORD=${DATABASE_PROD_PASSWORD}

[staging]
HOST=${DATABASE_STAGING_HOST}
NAME=myapp_staging
  • NAMESPACE=DATABASE prefixes all variables: HOST becomes DATABASE_HOST
  • [default] section is required — provides local development values
  • [production] and [staging] sections use ${VAR} references resolved from .env.shared or .env.local
  • Sections layer on top of [default]: production gets [default] + [production]

Profiles

JSON files that name an environment. default.json is required and lists all component names.

// env/profiles/default.json
{
  "name": "Default",
  "description": "Local development",
  "components": ["database", "redis", "auth"]
}

Profiles can extend other profiles:

// env/profiles/staging.json
{
  "name": "Staging",
  "extends": "production",
  "components": {
    "database": "staging"
  }
}

Staging inherits everything from production but overrides database to use the [staging] section.

Contracts

JSON files that declare what a service needs from the variable pool. The build fails atomically if any required variable is missing.

// env/contracts/api.contract.json
{
  "name": "api",
  "location": "apps/api",
  "required": {
    "DATABASE_URL": "postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}",
    "REDIS_URL": "REDIS_URL",
    "JWT_SECRET": "AUTH_JWT_SECRET"
  },
  "secret": {
    "STORAGE_ACCESS_KEY": "STORAGE_S3_ACCESS_KEY"
  },
  "optional": {
    "LOG_LEVEL": "LOG_LEVEL",
    "CORS_ORIGIN": "CORS_ORIGIN"
  },
  "defaults": {
    "LOG_LEVEL": "info",
    "CORS_ORIGIN": "http://localhost:3000"
  }
}

| Field | Purpose | |-------|---------| | name | Service identifier | | location | Where to write the .ce.{profile} file | | required | Variables the service must have — build fails if missing | | secret | Same as required, but flagged as sensitive | | optional | Variables used if available, skipped if not | | defaults | Fallback values for optional variables |

The left side is the app variable name (what the service sees). The right side is either a pool variable name or a template with ${VAR} substitutions.

Contracts also support TypeScript (.contract.ts) when a transpiler like tsx or jiti is available.

Shared & local values

| File | Purpose | Committed? | |------|---------|-----------| | env/.env.shared | Team-wide values | Yes | | env/.env.local | Personal overrides | Never (gitignored) |

.env.local always takes precedence over .env.shared. Both are applied after components.

# env/.env.shared — committed, shared across the team
DATABASE_PROD_HOST=db.example.com
DATABASE_PROD_USER=app_prod
REDIS_PROD_HOST=redis.example.com

# env/.env.local — gitignored, personal overrides
DATABASE_PORT=5433
LOG_LEVEL=debug

Vault — encrypted secrets

Optional feature. Requires additional dependencies:

npm install age-encryption sops-age @noble/curves @scure/base

The vault encrypts secret values directly in .env.shared so the file can be committed safely. Keys stay plaintext, values get encrypted inline. No external service needed.

Encryption uses age — a modern, audited encryption tool. Team access is managed via public keys, and your existing SSH key works automatically.

Set up the vault

# With CODEOWNERS protection (recommended)
ce vault init --github your-username

# Without CODEOWNERS
ce vault init

This creates env/.recipients and adds your public key. If you have ~/.ssh/id_ed25519, it's used automatically. Otherwise, a new age keypair is generated at ~/.config/composable.env/identity.

The --github flag also creates .github/CODEOWNERS to protect env/.recipients — preventing unauthorized changes to the recipient list via GitHub branch protection. If omitted, the GitHub CLI (gh) is checked for your username automatically.

Store a secret

ce vault set DATABASE_PROD_PASSWORD "s3cret-p@ssw0rd"

The value is encrypted and written to .env.shared:

DATABASE_PROD_PASSWORD=CENV_ENC[LS0tLS1CRUdJTi...]

Now you can safely commit .env.shared.

Read a secret

ce vault get DATABASE_PROD_PASSWORD
# → s3cret-p@ssw0rd

List encrypted keys

ce vault ls

Add a team member

# From GitHub (fetches their SSH public keys)
ce vault add --github alice

# From a raw age public key
ce vault add --key "age1abc123..." --comment "Bob"

Adding a recipient automatically re-encrypts all existing secrets so the new team member can decrypt them.

Remove a team member

ce vault remove alice

Removes the recipient and re-encrypts all secrets without their key.

List recipients

ce vault recipients

How it works during build

When ce build encounters CENV_ENC[...] values in .env.shared, it decrypts them transparently before building. No extra flags needed — if you can decrypt (you have a matching key), it just works.

Identity resolution order

  1. CE_AGE_KEY environment variable (raw age secret key — for CI)
  2. ~/.config/composable.env/identity (age identity file)
  3. ~/.ssh/id_ed25519 (auto-converted to age format)
  4. ~/.ssh/id_rsa (auto-converted to age format)

Legacy: CENV_AGE_KEY is also accepted as a fallback.

CI / deployment

Set CE_AGE_KEY as a secret in your CI environment:

# GitHub Actions example
env:
  CE_AGE_KEY: ${{ secrets.CE_AGE_KEY }}

To get the age secret key for CI, generate a dedicated key:

npx age-encryption -g  # prints identity + recipient
# Add the recipient: ce vault add --key "age1..."
# Set AGE-SECRET-KEY-1... as CE_AGE_KEY in CI

CLI reference

| Command | Description | |---------|-------------| | ce init [--examples] | Scaffold the env/ directory structure | | ce build [--profile name] | Build .ce.* files from a profile | | ce list | List available profiles | | ce run [--profile name] -- <cmd> | Load env and run a command (auto-builds) | | ce script <name> -c <cmd> | Inject a single profile-aware script | | ce scripts -c <cmd> [--actions dev,build,start] | Generate per-app scripts from contracts | | ce scripts:sync | Regenerate scripts from ce.json | | ce vault init [--github <user>] | Initialize the vault (optionally set up CODEOWNERS) | | ce vault set <KEY> <VALUE> | Encrypt and store a secret | | ce vault get <KEY> | Decrypt and print a secret | | ce vault ls | List encrypted keys | | ce vault add --github <user> | Add recipient from GitHub SSH keys | | ce vault add --key <key> | Add recipient from raw public key | | ce vault remove <identifier> | Remove a recipient | | ce vault recipients | List all recipients | | ce start [profile] | Build env + launch Zellij dev dashboard | | ce start --layout <name> | Use a custom layout template | | ce start --dry-run | Generate layout without launching | | ce uninstall [--all] [--dry-run] | Remove all composable.env artifacts |

Profile resolution

The --profile flag accepts a profile name. You can also:

  • Pass the profile as the last positional argument: ce run -- npm start production
  • Set the CE_PROFILE environment variable (legacy: CENV_PROFILE)
  • Default is "default" if nothing is specified

Execution — Zellij dev dashboard

Optional feature. Requires Zellij installed on your machine.

ce start launches a Zellij terminal dashboard where every service runs in its own pane — one command to get a full dev environment running.

Add dev commands to contracts

// env/contracts/api.contract.json
{
  "name": "api",
  "location": "apps/api",
  "required": { ... },
  "dev": {
    "command": "pnpm dev",
    "label": "API Server"
  }
}

| Field | Purpose | |-------|---------| | command | Shell command to run in the pane | | cwd | Working directory (defaults to location) | | label | Pane display name (defaults to uppercase name) |

Contracts without a dev field are skipped — they get env files but no pane.

Launch

ce start                    # default profile
ce start production         # named profile
ce start --dry-run          # generate layout, don't launch

This builds the env files, generates a Zellij KDL layout from your contracts, and opens a session with a pane per service.

Custom layouts

Drop a .kdl.template in env/execution/ for full control:

// env/execution/default.kdl.template
layout {
    tab name="Services" {
        pane name="API" {
            command "bash"
            args "-c" "cd ${PROJECT_ROOT}/apps/api && pnpm dev"
        }
        pane name="Web" {
            command "bash"
            args "-c" "cd ${PROJECT_ROOT}/apps/web && pnpm dev"
        }
    }
}

Templates use the same ${VAR} substitution as the rest of composable.env. ${PROJECT_ROOT} and ${PROFILE} are always available.

Generated .kdl files are gitignored. Template files (.kdl.template) are committed.


Monorepo / Turborepo setup

Install

# At the monorepo root
npm install -D composable.env

Initialize

ce init

This auto-detects turbo.json and adds env/** and .ce.* to globalDependencies.

Generate per-app scripts

ce scripts -c turbo --actions dev,build,start

This reads your contracts and generates scripts in package.json:

{
  "scripts": {
    "env:build:api": "ce build --profile",
    "dev:api": "ce run --profile -- turbo dev --filter=api",
    "build:api": "ce run --profile -- turbo build --filter=api",
    "start:api": "ce run --profile -- turbo start --filter=api",
    "dev": "ce run --profile default -- turbo dev",
    "build": "ce run --profile default -- turbo build",
    "start": "ce run --profile default -- turbo start"
  }
}

Usage:

# Build env for production, then start the api
pnpm env:build:api production
pnpm start:api production

# Or just dev everything locally
pnpm dev

The script config is saved to ce.json. Regenerate anytime with ce scripts:sync.

Service .env loading

Each app reads its .ce.{profile} file. In Next.js:

// next.config.js
const { config } = require('dotenv');
config({ path: `.ce.${process.env.CURRENT_ENV || 'default'}` });

In Express / Node:

require('dotenv').config({
  path: `.ce.${process.env.CURRENT_ENV || 'default'}`
});

Programmatic API

import { EnvironmentBuilder } from 'composable.env';

// Build from a profile
const builder = new EnvironmentBuilder(
  process.cwd(),   // configDir (where env/ lives)
  '.env',          // outputPath
  'production'     // envName (for .ce.{profile} files)
);

const result = await builder.buildFromProfile('production');
if (!result.success) {
  console.error(result.errors);
  process.exit(1);
}

// Vault API (requires vault dependencies installed)
const { Vault } = await import('composable.env/vault');
const vault = new Vault(process.cwd());
await vault.init();
await vault.setSecret('API_KEY', 'sk-123');
const value = await vault.getSecret('API_KEY');
await vault.addGitHubRecipient('alice');

Directory structure

your-project/
  env/
    components/           # Reusable variable definitions (INI with sections)
      database.env
      redis.env
      auth.env
    profiles/             # Named environment compositions (JSON)
      default.json        # Required — lists all component names
      production.json
      staging.json
    contracts/            # Per-service variable requirements (JSON or TS)
      api.contract.json
      web.contract.json
      worker.contract.json
    execution/            # Zellij layout templates (optional)
      default.kdl.template
    .env.shared           # Team values — committed (secrets encrypted via vault)
    .env.local            # Personal overrides — gitignored
    .recipients           # Vault recipient public keys — committed
  ce.json                 # Script config (if using ce scripts)
  .ce-managed.json        # Tracks what ce manages in package.json

How it works

  1. Load default.json to get all component names
  2. Resolve profile inheritance chain (e.g., staging extends production)
  3. Compose each component's sections in order: [default] + [production] + [staging]
  4. Layer .env.shared (team values) then decrypt CENV_ENC[...] values if present
  5. Layer .env.local (personal overrides — always wins)
  6. Resolve ${VAR} substitutions with multi-pass chaining
  7. Validate against all contracts — fail atomically if any required variable is missing
  8. Write one .ce.{profile} file per contract at the service's location

Uninstall

# See what would be removed
ce uninstall --dry-run

# Remove all generated files and managed keys
ce uninstall

# Also remove the env/ directory
ce uninstall --all

License

MIT