diff --git a/README.md b/README.md index dc4f4dd..1cb7dfe 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ npx branchly init # or: pnpm branchly init · yarn branchly init That single command discovers your stack, **installs the adapter packages it needs**, writes a `branchly.config.ts`, and wires up the Git hook. The necessary adapters and your package manager are detected automatically. -Branchly automatically detects a `.env` file, direnv, CI secrets, or your Prisma datasource. It keeps the host and credentials and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_` database for each branch. Nothing new to set. +Branchly automatically detects a `.env` file, Doppler, direnv, CI secrets, or your Prisma datasource. It keeps the host and credentials and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_` database for each branch. Nothing new to set. That's it. From now on, switching branches just works: diff --git a/packages/core/src/commands/init.test.ts b/packages/core/src/commands/init.test.ts index e548f4c..b41153d 100644 --- a/packages/core/src/commands/init.test.ts +++ b/packages/core/src/commands/init.test.ts @@ -123,12 +123,12 @@ describe('runInit', () => { reporter: createReporter({ quiet: true }), installer, env: {}, - envProvider: 'direnv', + envProvider: 'doppler', runCommand: () => Promise.resolve(true), }); expect(ok).toBe(true); const hook = await readFile(join(root, '.git', 'hooks', 'post-checkout'), 'utf8'); - expect(hook).toContain('direnv exec . '); + expect(hook).toContain('doppler run -- '); expect(hook).toContain('branchly on-checkout'); } finally { await rm(root, { recursive: true, force: true }); @@ -150,7 +150,7 @@ describe('runInit', () => { expect(ok).toBe(false); const hook = await readFile(join(root, '.git', 'hooks', 'post-checkout'), 'utf8'); expect(hook).toContain('branchly on-checkout'); - expect(hook).not.toContain('direnv exec'); + expect(hook).not.toContain('doppler run'); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/packages/core/src/init/audit-injection.test.ts b/packages/core/src/init/audit-injection.test.ts index 60ab6aa..676cf9a 100644 --- a/packages/core/src/init/audit-injection.test.ts +++ b/packages/core/src/init/audit-injection.test.ts @@ -33,17 +33,17 @@ const withDir = async (action: (root: string) => Promise): Promise = describe('auditInjection', () => { it('flags a detected injector whose wrapper is missing from the hook', async () => { await withDir(async (root) => { - await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8'); + await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8'); await writeHook(root, 'exec npx branchly on-checkout "$@"\n'); const unwrapped = unwrappedInjectors(await auditInjection(contextFor(root, () => Promise.resolve(true)))); - expect(unwrapped.map((finding) => finding.provider.id)).toEqual(['direnv']); + expect(unwrapped.map((finding) => finding.provider.id)).toEqual(['doppler']); }); }); it('does not flag when the hook already carries the wrapper', async () => { await withDir(async (root) => { - await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8'); - await writeHook(root, 'exec direnv exec . npx branchly on-checkout "$@"\n'); + await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8'); + await writeHook(root, 'exec doppler run -- npx branchly on-checkout "$@"\n'); const unwrapped = unwrappedInjectors(await auditInjection(contextFor(root, () => Promise.resolve(true)))); expect(unwrapped).toHaveLength(0); }); @@ -51,9 +51,10 @@ describe('auditInjection', () => { it('does not flag an injector that cannot resolve the key', async () => { await withDir(async (root) => { - await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8'); + await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8'); await writeHook(root, 'exec npx branchly on-checkout "$@"\n'); - const runner: CommandRunner = (command, args) => Promise.resolve(command === 'direnv' && args[0] === 'version'); + const runner: CommandRunner = (command, args) => + Promise.resolve(command === 'doppler' && args[0] === 'configure'); const unwrapped = unwrappedInjectors(await auditInjection(contextFor(root, runner))); expect(unwrapped).toHaveLength(0); }); diff --git a/packages/core/src/init/env-providers/doppler.ts b/packages/core/src/init/env-providers/doppler.ts new file mode 100644 index 0000000..774d5a4 --- /dev/null +++ b/packages/core/src/init/env-providers/doppler.ts @@ -0,0 +1,34 @@ +import { access } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { resolveProbe } from './command-runner'; +import type { EnvProvider, EnvProviderContext } from './types'; + +const CONFIG_FILES = ['doppler.yaml', '.doppler.yaml']; + +const fileExists = (path: string): Promise => + access(path) + .then(() => true) + .catch(() => false); + +const hasConfigFile = async (cwd: string): Promise => { + const checks = await Promise.all(CONFIG_FILES.map((name) => fileExists(join(cwd, name)))); + return checks.some((found) => found); +}; + +const hasConfiguredProject = (context: EnvProviderContext): Promise => + context.runCommand('doppler', ['configure', 'get', 'project', '--plain'], context.cwd); + +const detectDoppler = async (context: EnvProviderContext): Promise => + (await hasConfigFile(context.cwd)) || hasConfiguredProject(context); + +export const dopplerProvider: EnvProvider = { + id: 'doppler', + label: 'Doppler (doppler CLI)', + hookWrapMarker: 'doppler run --', + detect: detectDoppler, + wrapHookCommand: (command) => `doppler run -- ${command}`, + verifyResolves: (context) => + context.runCommand('doppler', ['run', '--', 'node', '-e', resolveProbe(context.key)], context.cwd), + describe: () => 'runs hooks under `doppler run` so Doppler injects the value', +}; diff --git a/packages/core/src/init/env-providers/env-providers.test.ts b/packages/core/src/init/env-providers/env-providers.test.ts index e43bc51..02816a2 100644 --- a/packages/core/src/init/env-providers/env-providers.test.ts +++ b/packages/core/src/init/env-providers/env-providers.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { direnvProvider } from './direnv'; +import { dopplerProvider } from './doppler'; import { envFileProvider } from './env-file'; import { detectEnvProviders, providerById } from './index'; import { shellProvider } from './shell'; @@ -59,37 +60,59 @@ describe('envFileProvider', () => { }); }); -describe('direnvProvider', () => { - it('detects only when both .envrc and the direnv binary are present', async () => { +describe('dopplerProvider', () => { + it('detects from a doppler.yaml file without running anything', async () => { await withDir(async (root) => { - expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(false); - await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8'); - expect(await direnvProvider.detect(contextFor(root, {}, never))).toBe(false); - expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(true); + await writeFile(join(root, 'doppler.yaml'), 'setup:\n', 'utf8'); + expect(await dopplerProvider.detect(contextFor(root, {}, never))).toBe(true); }); }); - it('wraps the hook with direnv exec and verifies via the runner', async () => { + it('detects from a configured project when no file is present', async () => { + await withDir(async (root) => { + expect(await dopplerProvider.detect(contextFor(root, {}, always))).toBe(true); + expect(await dopplerProvider.detect(contextFor(root, {}, never))).toBe(false); + }); + }); + + it('wraps the hook with doppler run and verifies via the runner', async () => { await withDir(async (root) => { const seen: string[][] = []; const runner: CommandRunner = (command, args) => { seen.push([command, ...args]); return Promise.resolve(true); }; - expect(direnvProvider.wrapHookCommand('npx branchly on-checkout')).toBe('direnv exec . npx branchly on-checkout'); - expect(await direnvProvider.verifyResolves(contextFor(root, {}, runner))).toBe(true); - expect(seen[0]?.slice(0, 2)).toEqual(['direnv', 'exec']); + expect(dopplerProvider.wrapHookCommand('npx branchly on-checkout')).toBe( + 'doppler run -- npx branchly on-checkout', + ); + expect(await dopplerProvider.verifyResolves(contextFor(root, {}, runner))).toBe(true); + expect(seen[0]?.slice(0, 3)).toEqual(['doppler', 'run', '--']); }); }); }); +describe('direnvProvider', () => { + it('detects only when both .envrc and the direnv binary are present', async () => { + await withDir(async (root) => { + expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(false); + await writeFile(join(root, '.envrc'), 'export DATABASE_URL=x\n', 'utf8'); + expect(await direnvProvider.detect(contextFor(root, {}, never))).toBe(false); + expect(await direnvProvider.detect(contextFor(root, {}, always))).toBe(true); + }); + }); + + it('wraps the hook with direnv exec', () => { + expect(direnvProvider.wrapHookCommand('npx branchly on-checkout')).toBe('direnv exec . npx branchly on-checkout'); + }); +}); + describe('detectEnvProviders / providerById', () => { it('returns matches in priority order and resolves ids', async () => { await withDir(async (root) => { await writeFile(join(root, '.env'), 'DATABASE_URL=x\n', 'utf8'); const detected = await detectEnvProviders(contextFor(root, { DATABASE_URL: 'x' }, never)); expect(detected.map((provider) => provider.id)).toEqual(['env-file', 'shell']); - expect(providerById('direnv')).toBe(direnvProvider); + expect(providerById('doppler')).toBe(dopplerProvider); expect(providerById('nope')).toBeNull(); }); }); diff --git a/packages/core/src/init/env-providers/index.ts b/packages/core/src/init/env-providers/index.ts index 075165f..774be3f 100644 --- a/packages/core/src/init/env-providers/index.ts +++ b/packages/core/src/init/env-providers/index.ts @@ -1,9 +1,10 @@ import { direnvProvider } from './direnv'; +import { dopplerProvider } from './doppler'; import { envFileProvider } from './env-file'; import { shellProvider } from './shell'; import type { EnvProvider, EnvProviderContext } from './types'; -export const ENV_PROVIDERS: readonly EnvProvider[] = [direnvProvider, envFileProvider, shellProvider]; +export const ENV_PROVIDERS: readonly EnvProvider[] = [dopplerProvider, direnvProvider, envFileProvider, shellProvider]; export const FALLBACK_PROVIDER: EnvProvider = envFileProvider; diff --git a/packages/core/src/init/hook.test.ts b/packages/core/src/init/hook.test.ts index b8a6554..40f3080 100644 --- a/packages/core/src/init/hook.test.ts +++ b/packages/core/src/init/hook.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { direnvProvider } from './env-providers/direnv'; +import { dopplerProvider } from './env-providers/doppler'; import { shellProvider } from './env-providers/shell'; import { appendHookLine, @@ -24,9 +24,9 @@ describe('hookCommand', () => { expect(hookCommand(POST_CHECKOUT_HOOK, identity, 'npm')).toBe('npx branchly on-checkout "$@"'); }); - it('wraps the command with the direnv injector', () => { - expect(hookCommand(POST_CHECKOUT_HOOK, direnvProvider.wrapHookCommand, 'npm')).toBe( - 'direnv exec . npx branchly on-checkout "$@"', + it('wraps the command with the doppler injector', () => { + expect(hookCommand(POST_CHECKOUT_HOOK, dopplerProvider.wrapHookCommand, 'npm')).toBe( + 'doppler run -- npx branchly on-checkout "$@"', ); }); @@ -146,16 +146,16 @@ describe('installHook', () => { } }); - it('wraps the hook with the direnv injector', async () => { + it('wraps the hook with the doppler injector', async () => { const root = await mkdtemp(join(tmpdir(), 'branchly-hook-')); try { const result = await installHook(root, POST_CHECKOUT_HOOK, { hooksPath: null, - injector: direnvProvider, + injector: dopplerProvider, manager: 'npm', }); - expect(result.injector).toBe('direnv'); - expect(await readFile(result.path, 'utf8')).toContain('direnv exec . npx branchly on-checkout'); + expect(result.injector).toBe('doppler'); + expect(await readFile(result.path, 'utf8')).toContain('doppler run -- npx branchly on-checkout'); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/packages/docs/src/content/docs/adapters/env-file.md b/packages/docs/src/content/docs/adapters/env-file.md index 2ca481c..d50cb2f 100644 --- a/packages/docs/src/content/docs/adapters/env-file.md +++ b/packages/docs/src/content/docs/adapters/env-file.md @@ -26,4 +26,4 @@ The write is an **upsert**: an existing `DATABASE_URL=` line is replaced in plac `branchly init` makes sure the file is gitignored — the value changes per branch and per machine, so it should never be committed. -Using direnv or CI-injected environments where a file write would be overridden at launch? Pair this with [`branchly run`](/branchly/guides/injected-envs/), or use the [direnv resolver](/branchly/adapters/direnv/). +Using Doppler or CI-injected environments where a file write would be overridden at launch? Pair this with [`branchly run`](/branchly/guides/injected-envs/), or use the [direnv resolver](/branchly/adapters/direnv/). diff --git a/packages/docs/src/content/docs/guides/cli.md b/packages/docs/src/content/docs/guides/cli.md index 97ae9c8..402adac 100644 --- a/packages/docs/src/content/docs/guides/cli.md +++ b/packages/docs/src/content/docs/guides/cli.md @@ -31,7 +31,7 @@ Tells you where you are: current branch, the key it maps to, whether the databas ## `branchly run -- ` -For setups where the environment is injected rather than read from a file (direnv, CI): provisions the current branch, then launches `` with the per-branch connection set in its environment — set last, so it wins over injected values. See [injected environments](/branchly/guides/injected-envs/). +For setups where the environment is injected rather than read from a file (Doppler, CI): provisions the current branch, then launches `` with the per-branch connection set in its environment — set last, so it wins over injected values. See [injected environments](/branchly/guides/injected-envs/). ## `branchly prune` diff --git a/packages/docs/src/content/docs/guides/configuration.md b/packages/docs/src/content/docs/guides/configuration.md index bee58c2..881dda6 100644 --- a/packages/docs/src/content/docs/guides/configuration.md +++ b/packages/docs/src/content/docs/guides/configuration.md @@ -34,7 +34,7 @@ Any other keys in an adapter's block are passed through to that adapter as optio ## `env()` references -`env('DATABASE_URL')` defers a value to the environment at load time, so your config can be committed without committing a connection string. branchly also auto-loads `.env` (non-overriding, so direnv and CI-injected values win). +`env('DATABASE_URL')` defers a value to the environment at load time, so your config can be committed without committing a connection string. branchly also auto-loads `.env` (non-overriding, so Doppler, direnv, and CI-injected values win). For the datasource `url`, branchly derives its maintenance connection from your app's existing connection string by swapping the database name — there is no separate admin variable to set. diff --git a/packages/docs/src/content/docs/guides/injected-envs.md b/packages/docs/src/content/docs/guides/injected-envs.md index 3a4fad0..c25f632 100644 --- a/packages/docs/src/content/docs/guides/injected-envs.md +++ b/packages/docs/src/content/docs/guides/injected-envs.md @@ -1,19 +1,20 @@ --- -title: direnv & injected environments +title: Doppler, direnv & injected environments description: Using branchly when your environment comes from a secret manager instead of a .env file. --- -The default resolver writes the per-branch connection into your `.env` file, which covers most projects. But plenty of teams inject environment instead — direnv, CI secrets. branchly is friendly to all of them, on both the input and the output side. +The default resolver writes the per-branch connection into your `.env` file, which covers most projects. But plenty of teams inject environment instead — Doppler, direnv, CI secrets. branchly is friendly to all of them, on both the input and the output side. ## The input side: where branchly finds your connection branchly derives everything from your existing `DATABASE_URL` — it keeps the host and credentials and swaps the database name. The catch is that your **git hooks** run in their own environment, which may differ from your shell. So during `init`, branchly detects _how_ that value reaches your project and wires the hooks to match: -- **`.env` files** are auto-loaded, _non-overriding_ — values already present in the process environment always win. So direnv- or CI-injected values take precedence naturally. -- **direnv** — detected from an `.envrc` plus the `direnv` binary; `init` wraps the hooks in `direnv exec`, so the hook sees the same values your app does. +- **`.env` files** are auto-loaded, _non-overriding_ — values already present in the process environment always win. So Doppler-, direnv-, or CI-injected values take precedence naturally. +- **Doppler** — detected from a `doppler.yaml` **or** a logged-in `doppler` CLI with a configured project. When chosen, `init` wraps the Git hooks in `doppler run`, so the hook sees the same secrets your app does. +- **direnv** — detected from an `.envrc` plus the `direnv` binary; `init` wraps the hooks in `direnv exec`. - **shell** — a value already exported in your environment. Convenient, but a fresh shell, CI runner, or git hook may not have it, so branchly says so rather than assuming. -In an interactive terminal, `init` confirms the detected source with you (and lets you pick another); in CI or with `--yes` it takes the top match, falling back to `.env`. Either way it then **verifies** that `DATABASE_URL` actually resolves the way the hooks will see it — if it can't, `init` tells you exactly what's missing instead of writing a config that silently won't work. You can pin the choice with `branchly init --env=direnv` (or `env-file`, `shell`), and re-check anytime with `branchly doctor`. +In an interactive terminal, `init` confirms the detected source with you (and lets you pick another); in CI or with `--yes` it takes the top match, falling back to `.env`. Either way it then **verifies** that `DATABASE_URL` actually resolves the way the hooks will see it — if it can't, `init` tells you exactly what's missing instead of writing a config that silently won't work. You can pin the choice with `branchly init --env=doppler` (or `direnv`, `env-file`, `shell`), and re-check anytime with `branchly doctor`. ## The output side: where your app finds the branch database @@ -30,10 +31,10 @@ resolver: { use: 'direnv', file: '.envrc', key: 'DATABASE_URL' } When your environment is injected at launch time, a file write isn't enough — the injector would overwrite it. `branchly run` solves the ordering: it provisions the current branch, then launches your command with the per-branch connection set **last**, so it wins: ```sh -direnv exec . branchly run -- npm run dev +doppler run -- branchly run -- npm run dev ``` -direnv injects your environment, then branchly overrides just the database URL with the current branch's. Works the same with any injector: +Doppler injects your secrets, then branchly overrides just the database URL with the current branch's. Works the same with any injector: ```sh branchly run -- npm run dev # plain diff --git a/packages/docs/src/content/docs/start/installation.md b/packages/docs/src/content/docs/start/installation.md index d014692..cd93d79 100644 --- a/packages/docs/src/content/docs/start/installation.md +++ b/packages/docs/src/content/docs/start/installation.md @@ -28,7 +28,7 @@ That single command: ## Connection detection -branchly never needs a new credential. It finds your existing connection — a `.env` file, direnv, CI secrets, or your Prisma datasource — keeps the host and credentials, and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_` database per branch. +branchly never needs a new credential. It finds your existing connection — a `.env` file, Doppler, direnv, CI secrets, or your Prisma datasource — keeps the host and credentials, and just swaps the database name: a maintenance connection for creating and cloning databases, and a fresh `app_` database per branch. ## Requirements diff --git a/packages/docs/src/content/docs/start/quickstart.md b/packages/docs/src/content/docs/start/quickstart.md index e7ef286..657f793 100644 --- a/packages/docs/src/content/docs/start/quickstart.md +++ b/packages/docs/src/content/docs/start/quickstart.md @@ -23,7 +23,7 @@ branchly init ◇ gitignore: .env is covered (branchly keeps its state in .git) ◇ checkout: installed at .husky/post-checkout 🪝 ◇ merge: installed at .husky/post-merge 🪝 -◇ next: nothing! branchly reuses your existing DATABASE_URL (.env, direnv, etc.) 🌱 +◇ next: nothing! branchly reuses your existing DATABASE_URL (.env, Doppler, etc.) 🌱 │ └ branchly is set up — happy branching! 🎉 ```