Dotenv ShiftSwitch Everything is configured in one Contents
Features
Quick start
A minimal config:
Configuration
Single repo example
Top-level fields
|
| Field | Type | Default | Description |
|---|---|---|---|
file |
string | required | Path of the env file. |
title |
string | file name | Name shown in the picker and status bar. Envs with the same title are switched together across apps. |
description |
string | none | Shown under the title in the picker and in the status bar tooltip. |
confirm |
boolean | false |
Ask before switching. Such an env is never applied automatically as the default. |
restart
Without restart (or without command), switching only copies files.
| Field | Type | Default | Description |
|---|---|---|---|
command |
string | none | Command that starts the app, e.g. pnpm dev, npm run dev, php artisan serve. |
enabled |
boolean | true |
Set to false to turn restarting off without removing the command. |
terminalName |
string | Dotenv Shift: <app or folder name> |
Name of the app terminal. |
cwd |
string | project or app folder | Working directory of the command. |
startIfNotRunning |
boolean | true |
Start the app when its terminal isn't open yet. With false, only running apps are restarted. |
killPorts |
array | ["PORT"] |
Ports to free before starting: port numbers, or env keys holding one (read from the old and the new env). [] turns it off. |
mode |
string | "auto" |
How the app is stopped: ctrlC sends Ctrl+C and reuses the terminal (scrollback is kept); recreate closes the terminal and opens a new one; auto is recreate on Windows and ctrlC elsewhere. |
Comments and trailing commas
The file is treated as JSON with comments (JSONC): // and /* */ comments and trailing commas
are allowed, and syntax errors are reported with their line number.
Paths
target, example, file and cwd are relative to the workspace folder, or to the app's dir
in a monorepo. Absolute paths work too, subfolders are fine ("file": "envs/.env.staging"), and
both / and \ are accepted on every OS.
The active env is stored in VS Code's workspace state, not in the config.
Monorepos
Add "apps" to switch several folders together. The top-level envs are shared: each file is
looked up inside every app's dir, and target and example are relative to each app.
my-monorepo/
├── dotenv-shift.json
├── .env.example .env.local .env.staging .env.production root
└── apps/
├── web/ .env.example .env.local .env.staging .env.production
└── api/ .env.example .env.local .env.staging .env.production .env.mock
{
"example": ".env.example",
"default": "Local",
"envs": [
{ "title": "Local", "file": ".env.local" },
{ "title": "Staging", "file": ".env.staging" },
{ "title": "Production", "file": ".env.production", "confirm": true }
],
"apps": [
{ "name": "root", "dir": "." },
{ "dir": "apps/web", "restart": { "command": "pnpm dev" } },
{
"dir": "apps/api",
"restart": { "command": "pnpm start:dev", "killPorts": ["PORT", 9229] },
"envs": [{ "title": "Mock", "description": "In-memory database", "file": ".env.mock" }]
}
]
}
apps[]
| Field | Type | Default | Description |
|---|---|---|---|
dir |
string | required | App folder, relative to the workspace folder. "." is the repo root. |
name |
string | last folder of dir (root for ".") |
Shown in the picker, messages and terminal title. |
target |
string | top-level target |
Overrides the target for this app. |
example |
string | top-level example |
Overrides the example for this app. |
restart |
object | none | Restarts this app in its own terminal. cwd defaults to dir. |
envs |
array | none | Per-app changes to the env list, see below. |
Per-app envs
An app's envs only lists what differs from the shared envs:
{
"dir": "apps/api",
"envs": [
{ "title": "Staging", "file": ".env.stage" }, // same env, different file name
{ "title": "Development", "file": null }, // api is left out of Development
{ "title": "Mock", "file": ".env.mock" } // only api has this env
]
}
- An entry with the title of a shared env overrides it:
file,descriptionorconfirm. "file": nullleaves the app out of that env. Switching all apps to it skips this app without a warning.- Any other title is an env only that app has.
Switching all apps or one
In a monorepo the picker has two steps: first the scope, then the env.
- All apps, or one app (
root,web,api, …). Each shows its current env. - The envs available for that scope. For All apps that's every env (one that only some apps have switches just those); for one app, its own list. Back returns to step 1.
Dotenv Shift: Switch Environment for One App skips the "All apps" entry.
- Only the switched apps are restarted: their own
restart, plus the top-levelrestartif there is one. - The active env is tracked per app. The status bar shows the env when all apps agree, otherwise Mixed, and the tooltip lists each app's env.
- A top-level
restartsuch as{ "command": "pnpm turbo dev" }runs from the root, with ports read from every app's env. - On restart, all affected apps are stopped first, then the ports are freed, then everything starts again, so apps can't hold each other's ports.
Create Config generates a monorepo config when it finds apps with .env* files under
apps/, packages/ or services/, and includes the root as an app if it has .env* files too.
Alternatively, open each app as a folder of a multi-root workspace with its own
dotenv-shift.json. Switching then asks which folder first.
How it works
Switching
- The env files are checked against the example. Missing keys open a dialog with Add Missing
& Switch and Switch Anyway. Envs with
confirm: trueask first. - If a target was edited by hand, you're asked to Save & Switch or Discard & Switch.
- Unsaved edits in the source file are saved, and the file is copied to the target.
- The app is restarted (see below).
Validation
Each env file is compared with the example:
- Missing keys are warnings in the Problems panel. The quick fix (lightbulb) appends them
with the example's values, under a
# added by Dotenv Shiftcomment, keeping the file's line endings. - Extra keys (not in the example) are informational.
- Dotenv Shift: Validate Env Files lists every file in the Dotenv Shift output channel as
[OK],[MISSING],[NOT FOUND]or[SKIPPED], and offers to add all missing keys.
Files are re-validated as you edit them.
Detecting the active env and hand edits
The active env is the one whose file has the same keys and values as the target. Comments,
spacing, quoting and order don't matter, so a reformatted copy of .env.staging still counts as
Staging. If .env is replaced by another env's content, the active env follows it.
When .env is edited and saved so that it matches no env:
- The active env stays, and the status bar shows it as
Local (modified)with a warning background. The tooltip lists the changes, e.g.changed: PORT · added: EXTRA. - A notification offers Show Diff, Save to
.env.local(keep the edits in the env file) and Discard Changes (restore.env). Dotenv Shift: Show .env Changes offers the same. - Switching away asks before overwriting the edits.
Deleting .env clears the active env.
Restarting and freeing ports
- Stop: Ctrl+C in the app terminal (
ctrlC), or close it, which ends its process tree (recreate). - Free ports: anything still listening on a port from
killPortsis stopped, gracefully first and forcefully after a grace period, even if it runs outside VS Code. If a port stays busy you get a warning. WithPORTinkillPorts, switching fromPORT=3000toPORT=3001frees both. - Start: the command runs in the app terminal, which is created if needed.
Freed ports are logged in the Dotenv Shift output channel, e.g. port 3000: killed PID 12345.
Default env
When a target doesn't exist (e.g. right after cloning), the default env is copied to it as
soon as the project opens:
- Existing targets are never overwritten, and the app isn't started.
- An env with
confirm: trueis never applied automatically. You get a notification with a button to switch instead. - In a monorepo, each app without a target gets the default.
- Dotenv Shift: Reset to Default Environment switches back to it at any time.
Security
Restart commands come from the workspace's dotenv-shift.json. In
Restricted Mode (an untrusted
workspace), restarting and freeing ports are disabled; switching and validating env files still
work. Env values are never written to the log or shown in messages, only key names.
Platform support
The extension runs on the workspace side (extensionKind: workspace), so in WSL, SSH or a Dev
Container it switches files and frees ports on that machine.
| Find the process on a port | Stop it | mode: auto |
|
|---|---|---|---|
| Windows | netstat -ano (any display language) |
taskkill /T, then /F |
recreate, avoiding the Terminate batch job (Y/N)? prompt of .cmd scripts |
| macOS | lsof |
SIGTERM, then SIGKILL |
ctrlC |
| Linux | lsof, else ss, else /proc (no tools needed) |
SIGTERM, then SIGKILL |
ctrlC |
Tools are also looked up in their usual locations (such as /usr/sbin), since editors launched
from a desktop often have a minimal PATH.
Commands
| Command | Description |
|---|---|
| Dotenv Shift: Switch Environment | Pick an env (in a monorepo: the scope, then the env), copy it to the target, restart. Also on status bar click. |
| Dotenv Shift: Switch Environment for One App | Monorepo: switch a single app. |
| Dotenv Shift: Show .env Changes | Review hand edits to a target: diff, save to the env file, or discard. |
| Dotenv Shift: Validate Env Files | Check every env file against the example, and add missing keys. |
| Dotenv Shift: Restart App | Restart without switching. |
| Dotenv Shift: Reset to Default Environment | Switch to the default env. |
| Dotenv Shift: Create Config (dotenv-shift.json) | Generate a config from the .env* files found. |
| Dotenv Shift: Open Config | Open dotenv-shift.json. |
| Dotenv Shift: Add Missing Keys to Env File | Append missing keys to an env's file in every app. |
Keybindings
dotenvShift.switch accepts an env title or file, or an object:
// keybindings.json
[
{ "key": "ctrl+alt+e", "command": "dotenvShift.switch" },
{ "key": "ctrl+alt+1", "command": "dotenvShift.switch", "args": "Local" },
{ "key": "ctrl+alt+m", "command": "dotenvShift.switch", "args": { "env": "Mock", "app": "api" } },
{ "key": "ctrl+alt+d", "command": "dotenvShift.showChanges", "args": { "action": "discard" } }
]
The object form takes env (title or file), app (monorepo: switch only that app) and folder
(a workspace folder URI, for multi-root workspaces).
dotenvShift.showChanges takes { "action": "diff" | "save" | "discard", "app", "folder" } to act
on a hand-edited target without asking.
Settings
| Setting | Default | Description |
|---|---|---|
dotenvShift.restartDelayMs |
300 |
Delay in ms between Ctrl+C and running the command again (ctrlC mode). |
API for other extensions
const api = vscode.extensions.getExtension('lufeasdev.dotenv-shift')?.exports;
api?.getStatus();
// { project: 'my-app', apps: [{ name: 'web', env: 'Local', modified: false, changes: undefined }] }
getStatus(folderUri?) returns each app's active env and whether its target was edited by hand
(with the changed, added and removed keys).
Development
Requirements for development: mise (installs the Node.js and pnpm versions
pinned in mise.toml) and VS Code 1.90+. Without mise: Node.js 22.12+ and pnpm 12.
mise install
pnpm install
pnpm compile # bundle with rolldown (or: pnpm watch)
pnpm typecheck
pnpm test # unit tests (vitest)
pnpm test:integration # integration tests in a real VS Code
pnpm package # build dotenv-shift-<version>.vsix
Trying it out
| Single repo | Monorepo | |
|---|---|---|
| Folder | sample/ |
sample-monorepo/ |
| Debugger (F5) | "Run Extension (sample)" | "Run Extension (sample-monorepo)" |
| Without debugger | pnpm dev |
pnpm dev:monorepo |
Other extensions are disabled in that window so they can't crash the debug host. Each sample has
a README listing what to try. Delete the generated .env files to see the default env applied
again.
Tests
- Unit tests (
test/*.test.ts, vitest) cover the env parser, config parsing, key and value comparison, port handling (including stopping a real process listening on a port), the project model, the active env store, the session's operation queue, and the pickers' two-step flow (driven by a fake QuickPick UI). Modules that importvscodeget a small mock (test/mocks/vscode.ts, wired invitest.config.ts). - Integration tests (
test/integration/) start VS Code on a temporary copy ofsample-monorepo(without restarts, so nothing touches your ports) and run the commands with arguments, so they don't depend on keyboard timing. SetVSCODE_EXECUTABLEto use an installed VS Code, e.g.VSCODE_EXECUTABLE=/usr/share/code/code pnpm test:integration; otherwise one is downloaded. - CI (
.github/workflows/ci.yml) runs typecheck, both test suites and the build on Linux, macOS and Windows.
Code quality
pnpm lint # Biome: lint + formatting check (also run in CI)
pnpm format # apply formatting and safe fixes
pnpm check # typecheck + lint + unit tests
Recommended editor setup is in .vscode/ (Biome formats on save), and .editorconfig covers
other editors.
Architecture
Layers only depend on the layers below them:
extension.ts wiring: creates the services, registers commands, returns the API
commands.ts command handlers (each failure is logged and reported, never unhandled)
ui/ status bar, pickers, quick fixes, prompts for hand-edited targets
workspace.ts one session per workspace folder with a config; reloads on change
session.ts a loaded project: file watchers, hand-edit tracking, revalidation,
and a queue that serialises file-changing operations
state.ts active env per app, kept in VS Code's workspace state
services/ switching, validation, restart, port killing, missing keys, Create Config
project.ts Project and App models, path resolution, file reading
core/ pure logic with no VS Code dependency (config, env parser, diff, ports)
core/must not importvscode, so it stays unit-testable with plain vitest.- Per-project state lives in
ProjectSession; disposing the session releases its watchers and timers, so reloading a config can't leak them. - Operations that write files go through
session.exclusive(), so overlapping commands (a double-clicked switch, a switch during a discard) run one after the other. - Command IDs are in
constants.ts;package.jsondeclares the same IDs.
Commits
Commits follow Conventional Commits, short and plain:
feat: add per-app envs
fix: keep crlf when adding keys
docs: explain monorepo picker
refactor: split commands from extension
test: cover concurrent switches
chore: bump rolldown
ci: run lint on windows
- Format:
type: what changed, lower case, at most 72 characters; add a body only when the why isn't obvious. - Types:
feat,fix,docs,refactor,perf,test,build,ci,chore,style,revert. - No AI co-author trailers.
pnpm install sets up a commit-msg hook that checks this with commitlint
(commitlint.config.mjs); pull requests are checked in CI too.
Localization
User-facing text goes through vscode.l10n.t(), and package.json strings live in
package.nls.json. Log messages stay in English.
pnpm l10n # regenerate l10n/bundle.l10n.json from the l10n.t() calls in src/
To add a language, copy l10n/bundle.l10n.json to l10n/bundle.l10n.<locale>.json and
package.nls.json to package.nls.<locale>.json (e.g. id, de), then translate the values.
Adding a feature
- A config option: add it to the types and parsing in
src/core/config.ts(with a unit test intest/config.test.ts), toschemas/dotenv-shift.schema.json, and to the README tables. - A command: add the ID to
src/constants.ts, topackage.json(contributes.commands) with its title inpackage.nls.json, a method toCommandHandlers, and its registration inregisterCommands. - User-facing text: wrap it in
vscode.l10n.t()and runpnpm l10n. - Behaviour across commands (e.g. something on every switch):
CommandHandlers.doSwitch.
License
MIT