LPM CLI

lpm env

Manage project environment variables and secrets — local-file storage, cloud sync, platform integrations, and OIDC policies.

lpm env set KEY=VALUE              # set one or many vars
lpm env get KEY                    # read one (masked unless --reveal)
lpm env list                       # list all (masked unless --reveal)
lpm env delete KEY                 # remove
lpm env push                       # encrypt + push to lpm.dev
lpm env pull                       # pull + decrypt
lpm env push --to vercel           # push to a connected platform
lpm env push --to coolify          # push to a connected Coolify app
lpm env push --to fly              # push write-only secrets to a Fly.io app
lpm env push --to railway          # push to a connected Railway target
lpm env push --to github-actions   # push variables + sealed secrets to GitHub

lpm env manages encrypted environment variables for the project. Local values use OS-keychain-backed storage, cloud sync persists only ciphertext to lpm.dev, and the Vercel, Coolify, Fly.io, Railway, and GitHub Actions integrations send application env values directly from the CLI to the selected platform.

For the underlying storage and encryption model, see env encryption architecture.

Local-file management

lpm env list                              # list all vars (values masked)
lpm env list --reveal                     # list with values shown
lpm env get DATABASE_URL                  # get one var
lpm env get DATABASE_URL --reveal         # get with value shown
lpm env set DATABASE_URL=postgres://...   # set one
lpm env set FOO=bar BAZ=qux               # set many at once
lpm env delete FOO BAZ                    # delete one or more
lpm env import .env.production            # import from a file
lpm env import .env --overwrite           # overwrite existing values
lpm env export .env.backup                # export to a file

Dotenv inputs are limited to 16 MiB per file before parsing. Missing optional .env* files retain their existing fallback behavior; an oversized import or run-time env file fails with its path and byte limit, before any configured script or child process is spawned. See local configuration size limits.

Project-bound environment commands validate lpm.json with the canonical schema before using aliases, inheritance, or environment mappings. A syntactically valid but semantically invalid file fails closed: lpm env pull and lpm env rotate-key stop before network access or local vault replacement, and lpm env share stops before sharing-key classification or registration. Malformed, oversized, and unreadable files follow the same fail-closed path. Account-level operations that do not consult project configuration remain independent.

Per-environment scoping

Pass --env=<name> to scope reads and writes to a named environment (e.g., staging, production). Environments are stored independently — staging and production keep separate value sets for the same key.

lpm env set --env=staging API_URL=https://staging.example.com
lpm env set --env=production API_URL=https://api.example.com
lpm env list --env=staging

Other local commands

lpm env init                              # interactive environment setup
lpm env ls                                # environment overview table with sync/update status
lpm env copy <src> <dst>                  # copy all secrets between envs
lpm env print                             # print resolved env (for `eval`)
lpm env check                             # check every env against lpm.json > envSchema
lpm env validate                          # compare the default vault with .env.example
lpm env example                           # generate `.env.example` from the schema

Validate local environments

lpm env check loads lpm.json > envSchema, discovers the project's environments from its configuration and local vault, then validates each resolved environment. It exits non-zero when any environment is invalid in both human and JSON modes.

lpm env check
lpm --json env check | jq -e '.success'

JSON output retains the per-environment validation details and sets "success": false for an invalid result, so automation can use either the process status or the structured field.

lpm env validate is a separate, narrower check. It compares the key names in the default local vault with the required names in .env.example; it does not inspect named environments, cloud state, or envSchema. Values assigned in .env.example are ignored.

lpm env validate
lpm env validate --strict
lpm --json env validate | jq -e '.valid'

Without --strict, the result is valid when every required key is present; extra keys in the default local vault are allowed. With --strict, those extra keys also make the result invalid. Invalid results exit non-zero in both human and JSON modes. In JSON, "success" and "valid" always agree while the required, present, missing, and extra fields remain available for structured inspection.

Resolved env surfaces such as lpm env print, lpm run, lpm <file> / lpm exec, and platform pushes ignore runtime-hook names such as NODE_OPTIONS, LD_PRELOAD, LD_AUDIT, BASH_ENV, and DYLD_INSERT_LIBRARIES when they come from env files, env secrets, or env-schema defaults. LPM CLI also strips inherited values for these names before spawning scripts.

Cloud sync

lpm env pull and lpm env push round-trip the local env project to lpm.dev's encrypted storage. The server stores ciphertext and wrapped key material; it never sees plaintext values.

lpm env push                              # encrypt + push the local env project
lpm env pull                              # pull + decrypt remote → local
lpm env diff                              # diff local vs remote (default env)
lpm env diff staging                      # diff local staging vs remote staging
lpm env diff staging production           # diff two local environments
lpm env share --org <org-slug>            # share the env project with an org
lpm env pair <CODE>                       # pair this device via dashboard code (interactive confirmation)
lpm env pair <CODE> --yes                 # skip the confirmation prompt (NOT recommended)
lpm env unpair                            # revoke this device's pairing
lpm env log                               # last 50 audit log entries
lpm env rotate-key                        # rotate the complete personal cloud payload
lpm env rotate-key --org <org-slug>       # rotate an organization content key
lpm env rotate-sharing-key                # rotate your X25519 sharing key (interactive, step-up reauth)

Cloud sync is plan-gated. Free accounts get local-only storage. Pro plans unlock personal sync, and active Organization plans unlock organization sync and sharing. Every push uses fresh randomized encryption and increments the server version, even when the plaintext did not change.

Pair a dashboard browser

Create a pairing code on the dashboard Secrets page. Then run this command on the CLI machine that holds your env key:

lpm env pair <CODE>

The browser and CLI exchange temporary P-256 public keys. Both clients derive an eight-digit comparison number from the shared ECDH secret.

The number has two groups of four digits. Make sure that the number in the terminal equals the dashboard number.

Type y only when the numbers are equal. The Registry relays the public keys, but it cannot calculate the shared secret.

The pairing code expires after five minutes. Use lpm env unpair to revoke all paired browsers.

Start pairing from an organization project for organization access. Personal and organization pairings use separate keys and browser storage.

Organization pairing requires the CLI machine with your registered account sharing key. It does not register or rotate a sharing key.

The browser can decrypt projects shared with your account. Owners, administrators, and maintainers can save changes; other members have read access.

The server checks current membership, subscription access, and key versions before a save. If rotation is required, an owner or administrator must run lpm env rotate --org <slug> first.

After a sharing-key change, pair the browser again and ask an owner or administrator to refresh your environment access.

CAUTION: The --yes flag skips the comparison. Use this flag only for a command that you copied from your trusted dashboard.

Re-encrypt personal cloud data

lpm env rotate-key pulls the authoritative personal cloud payload. It validates every environment boundary and re-encrypts the complete payload with a fresh data key.

The command uses the current remote version for a compare-and-swap write. It preserves named, empty, and remote-only environments and project metadata.

A conflict or an error before upload leaves the previous remote payload unchanged. A timeout after upload has an ambiguous result.

After a timeout, the CLI keeps its local version metadata unchanged. Run lpm env rotate-key again to use the authoritative remote version.

Rotate an organization content key

An organization owner or administrator can rotate the content key:

lpm env rotate-key --org <org-slug>

The command pulls and decrypts the authoritative organization payload. It validates the complete payload and creates a fresh content key.

The CLI re-encrypts the payload and wraps the new key for each current member who has a registered sharing key.

The server locks the current member and public-key rows. It rejects a missing, extra, stale, or incorrectly fingerprinted recipient.

The server then uses the remote version for a compare-and-swap write. One transaction replaces the ciphertext, advances the content-key version, and replaces all wraps.

If membership or a public key changes during rotation, the server returns a conflict. The previous ciphertext and wraps remain unchanged.

After you remove a member, run this command from a current owner or administrator machine. The machine must have current organization access.

Rotation prevents the removed key from decrypting future ciphertext. It cannot erase content keys or plaintext that a former member already copied.

Each organization wrap records three bindings: the recipient key version, the full SHA-256 fingerprint, and the organization content-key version.

If a binding is absent or stale, the server rejects the pull. The dashboard shows Needs share for that member.

Rotating your sharing key

lpm env rotate-sharing-key generates a fresh X25519 keypair, uploads the new public key after step-up authentication, and invalidates every organization env-project wrapped-key entry the server held for you. The CLI keeps the new private key in a pending slot until the server acknowledges the rotation, so a crash mid-flight is safe; re-running the command resumes the pending slot if the server already moved on. The flow:

  1. Prints the blast radius — every organization env project you can access needs an owner or admin to run lpm env share --org <slug> before pulls resume.
  2. Asks you to type ROTATE (uppercase) to confirm.
  3. Prompts for your password (and authenticator code, if MFA is enrolled) to mint the step-up proof.
  4. Uploads the new public key; the server invalidates the old wrapped-key rows and sends an out-of-band security email to your account plus an impact email to every affected org's owners and admins.
  5. Promotes the pending slot to the live keychain entry. The dashboard's env-project Member Access view shows the affected rows as Needs share.

rotate-sharing-key refuses to run without a TTY — there's no --yes / non-interactive path, by design. On every machine where you want to keep pulling, you re-register the rotated key the next time the CLI needs it: any org-touching verb (env share, env pull --org, etc.) detects a missing server-side key and walks you through a vault:public-key:set step-up to register it.

The pre-rotation key never re-establishes itself. If a teammate's machine still has the old private key it cannot decrypt anything pushed under the new AES key, and the dashboard's audit log records the rotation with both fingerprints so cross-team forensics are trivial.

Deployment platform integrations

Vercel, Coolify, Fly.io, Railway, and GitHub Actions are the supported env platforms. Application env values travel directly between the Rust CLI and the selected platform; they never pass through lpm.dev. lpm.dev stores the platform credential encrypted at rest, non-secret connection configuration, authorization scope, and bounded operation counts.

# Vercel
lpm env connect vercel --project=<id> --linked-env=production --target=production
lpm env push --to vercel                           # merge current env into Vercel
lpm env push --to vercel --env=production --clean  # remove Vercel-only values too
lpm env pull --from vercel --env=production        # import readable values into the local env

# Coolify
lpm env connect coolify --url=https://coolify.example.com \
  --application=<application-uuid> --linked-env=production
lpm env push --to coolify                          # merge into production values
lpm env push --to coolify --clean                  # remove Coolify-only production values
lpm env pull --from coolify --env=production

# Fly.io app secrets
lpm env connect fly --app=<fly-app-name> --linked-env=production
lpm env push --to fly                              # refresh every expected secret
lpm env push --to fly --clean                      # remove Fly-only secret names
lpm env pull --from fly --env=production           # reports names skipped; values are write-only

# Railway service values
lpm env connect railway --project=<project-id> \
  --environment=<environment-id> --service=<service-id> \
  --linked-env=production
lpm env push --to railway                          # merge into the service
lpm env push --to railway --clean                  # exact service user-values
lpm env pull --from railway --env=production

# Railway shared environment values
lpm env connect railway --project=<project-id> \
  --environment=<environment-id>

# GitHub repository or environment values
lpm env connect github-actions --repository=<owner/repository> \
  --environment=production --linked-env=production
lpm env push --to github-actions
lpm env push --to github-actions --clean
lpm env pull --from github-actions

lpm env status                                     # compare without mutation

The connect command prompts for the platform token and verifies it directly with the platform before saving the encrypted credential to lpm.dev.

For Vercel, optional connection flags are --team, --label, --linked-env, and comma-separated --target values (production, preview, development). Omitting --target selects all three. Targetless values, duplicate relevant keys, and variables shared with an unselected target fail closed before mutation.

For Coolify, create the token as a team administrator. Use either a root token or a token with read, write, and read:sensitive. Coolify hides application values from non-administrators and from tokens without read:sensitive; the CLI fails closed instead of importing a hidden value as an empty string.

--url must be the HTTPS origin of the Coolify instance, without credentials, a path, query parameters, or a fragment. --application selects the application UUID. Add --preview to target preview values instead of production values. --label and --linked-env have the same meaning as on Vercel. The CLI disables redirects for every Coolify request so the API token and env values cannot be forwarded to another origin.

The CLI syncs Coolify's raw stored value, not its deployment-rendered value, and preserves the existing literal, multiline, and shown-once flags during an update. Shared-variable references and shown-once values cannot be read authoritatively and are rejected before mutation. Convert shared references to application values, or delete and recreate shown-once values as readable values, before syncing.

For a new production key, the CLI first reserves the preview key with a unique non-secret ownership value. It creates production with that same non-secret value, then updates only the owned production row with the real value. A 409 Conflict is accepted only after Coolify confirms exactly one existing preview value; that pre-existing value is never changed or deleted. If a create response is lost, malformed, or too large to read safely, the CLI relists the application and recovers only an exact key, target, and unique operation-value match. Cleanup deletes only rows proven to belong to that operation by UUID or unique value; if recovery or cleanup cannot establish the final state, synchronization fails closed.

For Fly.io, --app selects one canonical app. Connect discovers and stores the immutable app ID, organization ID, and organization slug. Every later operation verifies the canonical name, app ID, and organization ID before mutation, so an app rename, transfer, deletion, or wrong target fails closed.

Fly.io app secrets are write-only. Every selected local key is sent as a Fly secret, regardless of envSchema. A push refreshes all expected values in one setSecrets release; --clean can create a second unsetSecrets release for extra names. If that removal response fails or disconnects, the CLI rereads the authoritative names and counts only keys confirmed absent; if the reread fails, no exact failed-operation audit is sent. Status is names_only when expected names exist, without claiming value equality. Pull cannot recover secret values and reports no_readable_values plus skippedSecrets. Fly-managed runtime names such as FLY_APP_NAME, FLY_REGION, and FLY_MACHINE_ID are excluded.

For Railway, --project and --environment are required. --service selects service variables; omit it to select shared environment variables. Account, workspace, and OAuth tokens use Bearer authentication. Add --project-token when the credential is a Railway project token so the CLI uses Project-Access-Token. Reads request unrendered values so ${{Service.VARIABLE}} references round-trip intact. The adapter disables redirects, treats GraphQL errors as failures even with HTTP 200, performs one bulk upsert, then rereads the target and reports success only after exact verification.

For GitHub Actions, --repository=<owner/name> is required. Add --environment=<name> to target environment-level values; omit it for repository-level values. The CLI stores the canonical repository name plus immutable numeric repository ID and verifies both before every operation. A rename, transfer, wrong repository, or unconfigured environment fails closed.

GitHub Actions variables are readable, but Actions secrets are write-only. Only a key explicitly declared as client: true and not secret: true in lpm.json > envSchema becomes an Actions variable. Undeclared keys, secret-marked keys, and client: true, secret: true keys become Actions secrets. Without an env schema, every key is a secret.

lpm.json
{
  "envSchema": {
    "vars": {
      "PUBLIC_ORIGIN": { "client": true },
      "API_TOKEN": { "secret": true },
      "AMBIGUOUS_TOKEN": { "client": true, "secret": true }
    }
  }
}

Each secret is sealed locally with GitHub's Actions public key; only ciphertext reaches GitHub. Status returns names_only when readable variables match and every expected secret name exists—it never claims that secret values are equal. Pull imports readable variables, preserves local-only values, reports skipped secret names, and never overwrites local secrets with unavailable values. Push refreshes every expected secret. --clean removes extra variable and secret names. After an ambiguous mutation response, the CLI rereads comparable variable or secret-name state before deciding whether the operation committed.

Fine-grained GitHub tokens need Metadata read access. Repository-scoped connections additionally need Variables read/write and Secrets read/write for full sync. Environment-scoped connections need Environments read/write. Read-only permission is sufficient only for status and readable pulls.

Normal pushes preserve values that exist only on the platform. --clean removes platform-only values within the configured target. Platform-managed variables are always excluded. Fly runtime names and Railway-provided runtime and Git variables are managed; user-configurable Railway names such as RAILWAY_DOCKERFILE_PATH remain syncable. lpm env status performs an exact name comparison for Fly.io and an exact value comparison where platforms return plaintext, so preserved platform-only values are reported as drift until they are removed or added locally.

Direct platform pushes record exact failed-operation counts only when acknowledged responses or authoritative final-state evidence prove them. Readable values can be reconciled by value, while Fly.io removals and GitHub secret creation or removal can be reconciled by name. An ambiguous existing-secret update remains unprovable because Fly.io and GitHub never return the value; in that case, or when reconciliation itself fails, the CLI returns the provider error without sending a falsely exact push_failed audit.

Encrypted cloud storage and platform deployment remain two explicit operations:

lpm env push                  # ciphertext + wrapped key to lpm.dev
lpm env push --to fly         # plaintext directly from the CLI into write-only Fly secrets
lpm env push --to railway     # plaintext directly from the CLI to Railway
lpm env push --to github-actions # values and sealed secrets directly to GitHub

The two systems cannot participate in an atomic transaction. A failure is reported for the operation that failed and does not claim to roll back a successful operation against the other system. After lpm env pull recovers the encrypted state on another paired machine, lpm env status verifies that state directly against the platform.

OIDC policies (CI)

OIDC pulls support personal Pro projects and organization projects on an active Organization or Enterprise plan. GitHub Actions and GitLab.com CI are supported. Self-managed GitLab issuers are not supported. CI does not store a long-lived Registry token.

lpm env push
lpm env oidc allow \
  --provider=github \
  --repo=owner/repo \
  --workflow=.github/workflows/deploy.yml \
  --branch=main \
  --env=production
lpm env oidc list

Run those commands from a logged-in development machine. lpm env push must create the personal cloud vault before oidc allow can enable CI escrow. The workflow path is mandatory and must be a file directly under .github/workflows/ ending in .yml or .yaml. The default allowed event is push. Use --events=push,workflow_dispatch to allow more than one.

For an organization project, an owner or admin must first share or pull its current encrypted revision. Then explicitly enable server decryption:

lpm env pull --org=acme
lpm env oidc allow \
  --org=acme \
  --allow-server-decryption \
  --provider=github \
  --repo=owner/repo \
  --workflow=.github/workflows/deploy.yml \
  --branch=main \
  --env=production
lpm env oidc list --org=acme

This opt-in gives the Registry this project's current content key. It does not transfer your organization sharing private key or personal wrapping key. The Registry can decrypt the project's environments for CI jobs that match the policy.

The organization owns these policies and CI credentials. They do not depend on the original policy creator remaining an admin. The Registry checks current billing, policy restrictions, token validity, and content-key state at each pull.

Content-key rotation or membership key invalidation disables CI decryption and revokes issued credentials. After the new key is shared, an owner or admin must run the complete oidc allow command again.

Owners and admins can add or replace policies, delete policies, and disable CI decryption from the project's Access page. To disable it from the CLI:

lpm env oidc disable --org=acme

Disabling decryption also revokes existing CI credentials. Enabling it again does not restore those credentials. Disabling remains available when organization billing lapses.

The CI pull command is the same for personal and organization projects. Its policy ID selects the ownership scope, so the CI job does not need --org.

After a successful oidc allow, LPM CLI prints the server-issued policy ID. Copy this UUID into the CI configuration as LPM_OIDC_POLICY_ID.

The policy ID is a selector, not a bearer credential. It cannot authorize a pull without a valid CI identity token. Protect its integrity because a changed value can select the wrong policy.

Each lpm env pull --oidc command requires this selector. You can pass --policy-id=<uuid> instead. The command flag takes precedence over LPM_OIDC_POLICY_ID.

By default, LPM CLI gets the immutable numeric repository ID from the GitHub API. Public repository requests do not need GitHub authentication.

If the repository is private, set GITHUB_TOKEN or GH_TOKEN before you run the command. Alternatively, add --repository-id=<numeric-id>.

The policy binds the repository name and its numeric ID. Both values must match during the OIDC exchange and the CI pull.

A repository rename or transfer stops CI access. Run oidc allow again with the current repository name.

A different repository cannot use the policy if it reuses the old name. The new repository has a different numeric ID.

Existing GitHub policies without a repository ID fail closed. If lpm env oidc list reports a missing ID, run oidc allow again.

GitLab.com uses the stable numeric project_id claim and has no GitHub workflow/event/fork fields:

lpm env oidc allow \
  --provider=gitlab \
  --project-id=12345 \
  --branch=main \
  --env=production

--repo, --repository-id, --workflow, --events, and --allow-forks are rejected for GitLab.com policies. The canonical subject is project:12345. Exchanges require ref_type: "branch". A tag whose name matches an allowed branch is still rejected.

lpm env oidc allow replaces the policy's complete allowlists rather than merging an individual branch or environment. Before changing an existing policy, inspect it with lpm env oidc list and review the full replacement surface with lpm env oidc allow --help.

The policy command succeeds only after it creates or updates the policy, retrieves the local wrapping key, and uploads the escrow material needed for server-side CI decryption. If wrapping-key retrieval or escrow upload fails, the command exits non-zero without reporting policy success. The policy may already exist, but CI pulls are not ready; fix the escrow problem and rerun the same command. Rerunning safely updates the matching policy.

For lpm env pull --oidc, a non-empty LPM_VAULT_ID takes precedence over lpm.json > vault and can bootstrap a checkout that has no local vault field. If the variable is unset, empty, or whitespace-only, LPM CLI falls back to the local field. Commit the generated local value or pass the same vault ID through the workflow environment.

.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install -g @lpm-registry/cli
      - name: Load production environment
        run: lpm env pull --oidc --env=production --output=.env
        env:
          LPM_VAULT_ID: 7f3a1e2c-5b9d-4a8f-b6c1-9b1d2e3f4a5b
          LPM_OIDC_POLICY_ID: ${{ vars.LPM_OIDC_POLICY_ID }}
      - run: ./deploy.sh

GitHub exposes its OIDC runtime endpoint when the job has id-token: write. LPM CLI requests the https://lpm.dev audience automatically. The Registry checks the repository name, numeric ID, workflow, event, branch, and environment before it returns values.

Create LPM_OIDC_POLICY_ID as a GitHub repository variable for this job. If you use an environment variable, add environment: <name> to the job. GitHub does not expose an environment variable to a job that does not target that environment.

GitLab.com CI supplies LPM_OIDC_TOKEN with audience https://lpm.dev; LPM CLI binds it to the exact numeric project ID. Self-managed GitLab issuers are not supported. Organization projects use the same CI token exchange after explicit decryption setup.

.gitlab-ci.yml
deploy:
  id_tokens:
    LPM_OIDC_TOKEN:
      aud: https://lpm.dev
  variables:
    LPM_VAULT_ID: 7f3a1e2c-5b9d-4a8f-b6c1-9b1d2e3f4a5b
  script:
    - npm install -g @lpm-registry/cli
    - lpm env pull --oidc --env=production --output=.env
    - ./deploy.sh

Create LPM_OIDC_POLICY_ID as a GitLab CI/CD variable. Mark it protected only when every branch or tag allowed by the OIDC policy is also protected. GitLab does not expose protected variables to unprotected refs.

CI output

lpm env print --ci --env=staging
lpm env export --ci --env=staging ci.env

lpm env print --ci emits resolved environment variables in a CI-native format. GitHub Actions runners get GitHub's masked-output format, Vercel gets dotenv, and generic CI gets shell-export lines.

lpm env export --ci <file> writes a dotenv-formatted copy to the given path. --env=<mode> selects which environment file is loaded. Secret-marked vars from lpm.json > envSchema are masked in CI-native output where the platform supports it.

lpm ci is still the frozen-lockfile install command. See lpm install.

Flags

FlagEffect
--env=<name>Scope to a named environment (default: default)
--revealShow values in get / list (default is masked)
--overwriteimport overwrites existing keys instead of skipping
--to <vercel|coolify|fly|railway|github-actions>Push directly from the CLI to the connected platform
--from <vercel|coolify|fly|railway|github-actions>Pull readable values directly from the platform into the selected local env
--cleanReplace platform values instead of merging
--yesSkip confirmation prompts
--ciEmit CI-native output for print or dotenv output for export
--oidcPull a personal Pro or approved organization project through GitHub Actions or GitLab.com CI OIDC
--policy-id=<uuid>Select the exact OIDC policy. This flag takes precedence over LPM_OIDC_POLICY_ID
--output <file>Write pull results to a file instead of local env storage
--org <slug>Org-scoped sync target

Plus the global flags.

See also