diff --git a/guides/dotenv-editor.md b/guides/dotenv-editor.md index 149d41a5..b562fb60 100644 --- a/guides/dotenv-editor.md +++ b/guides/dotenv-editor.md @@ -147,7 +147,43 @@ If the target file does not exist anywhere under `paths`, but a template exists - `templateExtension: 'template'` by default, producing patterns like `.env.local.template`. -If neither the target nor the template exists under any provided path, `editDotenvFile` throws. +If neither the target nor the template exists under any provided path, `editDotenvFile` throws, and the error message lists every candidate filename it checked. + +### Shared env template fallback (`scope: 'env'` only) + +Env-scoped templates are usually identical across environments (`dev`, `stg`, `prod`, ...), which means keeping separate `.env..local.template` files per environment just invites drift. When an env-scoped target is missing *and* it has no env-specific template, `resolveDotenvTarget` falls back to a **shared env template** before throwing. + +The shared template filename is built the same way as the env target, but with a fixed token in place of the actual env name. That token is configurable via `sharedEnvTemplateToken` (default `'env'`), and it respects `dotenvToken`, `privateToken`, and `templateExtension`: + +- `.env..local` → shared template `.env.env.local.template` +- `.env.` → shared template `.env.env.template` + +Resolution order per path, in the configured `searchOrder`: + +1. The target itself (e.g. `.env.dev.local`) — if it exists anywhere under `paths`, it wins outright. +2. The env-specific template (e.g. `.env.dev.local.template`) — this **always** takes priority over the shared template when present, in any searched path. +3. Only when neither of the above is found anywhere under `paths`: the shared env template (e.g. `.env.env.local.template`), searched again across the same ordered paths. + +Notes: + +- This fallback only applies to `scope: 'env'`. Global-scoped targets (`.env`, `.env.local`) never use it — `.env.local.template` remains the template for `.env.local` only. +- When a shared template is selected, `resolveDotenvTarget` returns the env target path (in the directory where the shared template was found) as `targetPath`, and the shared template path as `templatePath`. `editDotenvFile` bootstraps from it exactly like any other template, reporting `createdFromTemplate: true`. + +```ts +import { editDotenvFile } from '@karmaniverous/get-dotenv'; + +// With `.env.env.local.template` committed at the repo root, this works for +// every env without a per-env template file. +await editDotenvFile( + { API_URL: 'https://example.com' }, + { + paths: ['./'], + scope: 'env', + privacy: 'private', + env: 'dev', + }, +); +``` ### Example: create `.env.local` from template and set keys diff --git a/guides/stan-assistant-guide/editing.md b/guides/stan-assistant-guide/editing.md index 3845f434..d7e79660 100644 --- a/guides/stan-assistant-guide/editing.md +++ b/guides/stan-assistant-guide/editing.md @@ -41,6 +41,7 @@ await editDotenvFile( - `searchOrder: 'reverse'` (default): last path wins. - `searchOrder: 'forward'`: first path wins. - Template bootstrap: if the target is missing but `.` exists (default `template`), it is copied first. +- Shared env template fallback (`scope: 'env'` only): if neither the target nor its env-specific template exists, a shared template is tried next. It's named the same way as the env target, but with a fixed token (`sharedEnvTemplateToken`, default `'env'`) in place of the env name — e.g. `.env.env.local.template` for `.env..local`, or `.env.env.template` for `.env.`. An env-specific template always wins when present. Global scope never uses this fallback. - Return shape: `{ path, createdFromTemplate, changed }`. ### Selector mapping diff --git a/guides/stan-assistant-guide/index.md b/guides/stan-assistant-guide/index.md index ce8b29c0..209196a7 100644 --- a/guides/stan-assistant-guide/index.md +++ b/guides/stan-assistant-guide/index.md @@ -170,6 +170,7 @@ Notes: - `searchOrder: 'reverse'` (default): last path wins (highest precedence). - `searchOrder: 'forward'`: first path wins. - Template bootstrap: if the selected target is missing but `.` exists (default extension: `template`), the template is copied first and then edited in place. +- Shared env template fallback (`scope: 'env'` only): if neither the target nor its env-specific template exists anywhere under `paths`, a shared template named with a fixed token in place of the env name (`sharedEnvTemplateToken`, default `'env'`) is tried next — e.g. `.env.env.local.template` for `.env..local`. An env-specific template always wins over the shared one; global scope never uses this fallback. See [Dotenv editor](../dotenv-editor.md#shared-env-template-fallback-only) for the full resolution order. - Return shape: `editDotenvFile` returns `{ path, createdFromTemplate, changed }` where `path` is absolute. Selector mapping (filename construction): diff --git a/src/dotenv/edit/editDotenvFile.test.ts b/src/dotenv/edit/editDotenvFile.test.ts index dac8439b..eaac1c04 100644 --- a/src/dotenv/edit/editDotenvFile.test.ts +++ b/src/dotenv/edit/editDotenvFile.test.ts @@ -77,6 +77,29 @@ describe('dotenv/edit/editDotenvFile', () => { expect(txt).toBe('A=updated\n'); }); + it('bootstraps from the shared env template when no env-specific template exists', async () => { + const d = await mk(path.join(ROOT, 'case5', 'p')); + await fs.writeFile( + path.join(d, '.env.env.local.template'), + 'A=shared\n', + 'utf-8', + ); + + const res = await editDotenvFile( + { A: 'updated' }, + { + paths: [d], + scope: 'env', + privacy: 'private', + env: 'dev', + }, + ); + expect(res.createdFromTemplate).toBe(true); + expect(res.path).toBe(path.resolve(d, '.env.dev.local')); + const txt = await fs.readFile(res.path, 'utf-8'); + expect(txt).toBe('A=updated\n'); + }); + it('throws when env-scoped selection is requested without an env', async () => { const d = await mk(path.join(ROOT, 'case4', 'p')); await fs.writeFile(path.join(d, '.env'), 'A=1\n', 'utf-8'); diff --git a/src/dotenv/edit/editDotenvFile.ts b/src/dotenv/edit/editDotenvFile.ts index b8007b0c..7cdb4367 100644 --- a/src/dotenv/edit/editDotenvFile.ts +++ b/src/dotenv/edit/editDotenvFile.ts @@ -62,6 +62,9 @@ export async function editDotenvFile( privacy: options.privacy, templateExtension, searchOrder, + ...(typeof options.sharedEnvTemplateToken === 'string' + ? { sharedEnvTemplateToken: options.sharedEnvTemplateToken } + : {}), }); let createdFromTemplate = false; diff --git a/src/dotenv/edit/resolveDotenvTarget.test.ts b/src/dotenv/edit/resolveDotenvTarget.test.ts index 4c3c9d79..8d7a335d 100644 --- a/src/dotenv/edit/resolveDotenvTarget.test.ts +++ b/src/dotenv/edit/resolveDotenvTarget.test.ts @@ -94,4 +94,339 @@ describe('dotenv/edit/resolveDotenvTarget', () => { buildDotenvTargetFilename({ scope: 'env', privacy: 'public' }), ).toThrow(/env is required/i); }); + + describe('shared env template fallback', () => { + it('env-specific template wins over the shared one (same dir)', async () => { + const d = path.join(ROOT, 'shared1', 'p'); + await fs.remove(path.join(ROOT, 'shared1')); + await fs.ensureDir(d); + + await fs.writeFile( + path.join(d, '.env.dev.local.template'), + 'A=env-specific\n', + 'utf-8', + ); + await fs.writeFile( + path.join(d, '.env.env.local.template'), + 'A=shared\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d], + scope: 'env', + privacy: 'private', + env: 'dev', + }); + + expect(out.targetPath).toBe(path.resolve(d, '.env.dev.local')); + expect(out.templatePath).toBe(path.resolve(d, '.env.dev.local.template')); + }); + + it('env-specific template wins over the shared one (different dirs)', async () => { + const d1 = path.join(ROOT, 'shared2', 'p1'); + const d2 = path.join(ROOT, 'shared2', 'p2'); + await fs.remove(path.join(ROOT, 'shared2')); + await fs.ensureDir(d1); + await fs.ensureDir(d2); + + // Shared template in d1 (checked first in reverse order's second pass, + // but the env-specific template loop runs to completion first). + await fs.writeFile( + path.join(d1, '.env.env.local.template'), + 'A=shared\n', + 'utf-8', + ); + // Env-specific template only in d2. + await fs.writeFile( + path.join(d2, '.env.dev.local.template'), + 'A=env-specific\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d1, d2], + scope: 'env', + privacy: 'private', + env: 'dev', + }); + + expect(out.targetPath).toBe(path.resolve(d2, '.env.dev.local')); + expect(out.templatePath).toBe( + path.resolve(d2, '.env.dev.local.template'), + ); + }); + + it('uses the shared template when no env-specific template exists (private)', async () => { + const d = path.join(ROOT, 'shared3', 'p'); + await fs.remove(path.join(ROOT, 'shared3')); + await fs.ensureDir(d); + + await fs.writeFile( + path.join(d, '.env.env.local.template'), + 'A=shared\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d], + scope: 'env', + privacy: 'private', + env: 'dev', + }); + + expect(out.targetPath).toBe(path.resolve(d, '.env.dev.local')); + expect(out.templatePath).toBe(path.resolve(d, '.env.env.local.template')); + }); + + it('uses the shared template when no env-specific template exists (public)', async () => { + const d = path.join(ROOT, 'shared4', 'p'); + await fs.remove(path.join(ROOT, 'shared4')); + await fs.ensureDir(d); + + await fs.writeFile( + path.join(d, '.env.env.template'), + 'A=shared\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d], + scope: 'env', + privacy: 'public', + env: 'dev', + }); + + expect(out.targetPath).toBe(path.resolve(d, '.env.dev')); + expect(out.templatePath).toBe(path.resolve(d, '.env.env.template')); + }); + + it('global scope never uses the shared env template', async () => { + const d = path.join(ROOT, 'shared5', 'p'); + await fs.remove(path.join(ROOT, 'shared5')); + await fs.ensureDir(d); + + // This file would match the shared-template naming pattern if scope were + // 'env', but for 'global' scope it must be ignored entirely. + await fs.writeFile( + path.join(d, '.env.env.local.template'), + 'A=shared\n', + 'utf-8', + ); + + await expect( + resolveDotenvTarget({ + fs: port, + paths: [d], + scope: 'global', + privacy: 'private', + }), + ).rejects.toThrow(/Unable to locate dotenv target/i); + }); + + it('.env.local.template remains the template for .env.local only (global)', async () => { + const d = path.join(ROOT, 'shared6', 'p'); + await fs.remove(path.join(ROOT, 'shared6')); + await fs.ensureDir(d); + + await fs.writeFile( + path.join(d, '.env.local.template'), + 'A=global-template\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d], + scope: 'global', + privacy: 'private', + }); + + expect(out.targetPath).toBe(path.resolve(d, '.env.local')); + expect(out.templatePath).toBe(path.resolve(d, '.env.local.template')); + }); + + it('supports a custom sharedEnvTemplateToken', async () => { + const d = path.join(ROOT, 'shared7', 'p'); + await fs.remove(path.join(ROOT, 'shared7')); + await fs.ensureDir(d); + + await fs.writeFile( + path.join(d, '.env.shared.local.template'), + 'A=shared\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d], + scope: 'env', + privacy: 'private', + env: 'dev', + sharedEnvTemplateToken: 'shared', + }); + + expect(out.targetPath).toBe(path.resolve(d, '.env.dev.local')); + expect(out.templatePath).toBe( + path.resolve(d, '.env.shared.local.template'), + ); + }); + + it('respects custom dotenvToken, privateToken, and templateExtension', async () => { + const d = path.join(ROOT, 'shared8', 'p'); + await fs.remove(path.join(ROOT, 'shared8')); + await fs.ensureDir(d); + + await fs.writeFile( + path.join(d, 'env.env.priv.tmpl'), + 'A=shared\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d], + scope: 'env', + privacy: 'private', + env: 'dev', + dotenvToken: 'env', + privateToken: 'priv', + templateExtension: 'tmpl', + }); + + expect(out.targetPath).toBe(path.resolve(d, 'env.dev.priv')); + expect(out.templatePath).toBe(path.resolve(d, 'env.env.priv.tmpl')); + }); + + it('finds the shared template across multiple paths (reverse order)', async () => { + const d1 = path.join(ROOT, 'shared9', 'p1'); + const d2 = path.join(ROOT, 'shared9', 'p2'); + await fs.remove(path.join(ROOT, 'shared9')); + await fs.ensureDir(d1); + await fs.ensureDir(d2); + + await fs.writeFile( + path.join(d1, '.env.env.local.template'), + 'A=p1-shared\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d1, d2], + scope: 'env', + privacy: 'private', + env: 'dev', + }); + + // Reverse order checks d2 then d1 in the shared-template pass; only d1 has it. + expect(out.targetPath).toBe(path.resolve(d1, '.env.dev.local')); + expect(out.templatePath).toBe( + path.resolve(d1, '.env.env.local.template'), + ); + }); + + it('finds the shared template across multiple paths (forward order)', async () => { + const d1 = path.join(ROOT, 'shared10', 'p1'); + const d2 = path.join(ROOT, 'shared10', 'p2'); + await fs.remove(path.join(ROOT, 'shared10')); + await fs.ensureDir(d1); + await fs.ensureDir(d2); + + await fs.writeFile( + path.join(d1, '.env.env.local.template'), + 'A=p1-shared\n', + 'utf-8', + ); + await fs.writeFile( + path.join(d2, '.env.env.local.template'), + 'A=p2-shared\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d1, d2], + scope: 'env', + privacy: 'private', + env: 'dev', + searchOrder: 'forward', + }); + + expect(out.targetPath).toBe(path.resolve(d1, '.env.dev.local')); + expect(out.templatePath).toBe( + path.resolve(d1, '.env.env.local.template'), + ); + }); + + it('an existing target wins over the shared template and everything else', async () => { + const d1 = path.join(ROOT, 'shared11', 'p1'); + const d2 = path.join(ROOT, 'shared11', 'p2'); + await fs.remove(path.join(ROOT, 'shared11')); + await fs.ensureDir(d1); + await fs.ensureDir(d2); + + await fs.writeFile( + path.join(d1, '.env.env.local.template'), + 'A=shared\n', + 'utf-8', + ); + await fs.writeFile( + path.join(d1, '.env.dev.local.template'), + 'A=env-specific\n', + 'utf-8', + ); + await fs.writeFile( + path.join(d2, '.env.dev.local'), + 'A=existing\n', + 'utf-8', + ); + + const out = await resolveDotenvTarget({ + fs: port, + paths: [d1, d2], + scope: 'env', + privacy: 'private', + env: 'dev', + }); + + expect(out.targetPath).toBe(path.resolve(d2, '.env.dev.local')); + expect(out.templatePath).toBeUndefined(); + }); + + it('error lists every candidate filename checked, including the shared template', async () => { + const d1 = path.join(ROOT, 'shared12', 'p1'); + const d2 = path.join(ROOT, 'shared12', 'p2'); + await fs.remove(path.join(ROOT, 'shared12')); + await fs.ensureDir(d1); + await fs.ensureDir(d2); + + await expect( + resolveDotenvTarget({ + fs: port, + paths: [d1, d2], + scope: 'env', + privacy: 'private', + env: 'dev', + }), + ).rejects.toThrow( + new RegExp( + [ + path.resolve(d2, '.env.dev.local'), + path.resolve(d2, '.env.dev.local.template'), + path.resolve(d1, '.env.dev.local'), + path.resolve(d1, '.env.dev.local.template'), + path.resolve(d2, '.env.env.local.template'), + path.resolve(d1, '.env.env.local.template'), + ] + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'), + ), + ); + }); + }); }); diff --git a/src/dotenv/edit/resolveDotenvTarget.ts b/src/dotenv/edit/resolveDotenvTarget.ts index 971afdae..82bd1e9c 100644 --- a/src/dotenv/edit/resolveDotenvTarget.ts +++ b/src/dotenv/edit/resolveDotenvTarget.ts @@ -123,6 +123,20 @@ export interface ResolveDotenvTargetOptions extends BuildDotenvTargetFilenameOpt searchOrder?: DotenvPathSearchOrder; /** Template extension used for bootstrap discovery. Defaults to `'template'`. */ templateExtension?: string; + /** + * Env name token substituted in place of the real env name when building the + * shared env template filename (`scope: 'env'` only). For example, with the + * default token `'env'`, the shared template for `.env..local` is + * `.env.env.local.template`, and for `.env.` it is `.env.env.template`. + * + * The shared env template is used as a last-resort fallback when neither the + * env-scoped target nor its env-specific template (`.`) + * exists anywhere under `paths`. An env-specific template always takes priority + * over the shared one. This option has no effect for `scope: 'global'`. + * + * @defaultValue `'env'` + */ + sharedEnvTemplateToken?: string; } /** @@ -145,22 +159,55 @@ export async function resolveDotenvTarget( const filename = buildDotenvTargetFilename(opts); const templateExtension = opts.templateExtension ?? 'template'; const searchOrder = opts.searchOrder ?? 'reverse'; + const sharedEnvTemplateToken = opts.sharedEnvTemplateToken ?? 'env'; const pathsOrdered = orderedPaths(opts.paths, searchOrder); + // The shared env template filename is built the same way as the env target, + // but with a fixed token in place of the actual env name (scope: 'env' only). + const sharedEnvTemplateFilename = + opts.scope === 'env' + ? `${buildDotenvTargetFilename({ + ...(typeof opts.dotenvToken === 'string' + ? { dotenvToken: opts.dotenvToken } + : {}), + ...(typeof opts.privateToken === 'string' + ? { privateToken: opts.privateToken } + : {}), + scope: 'env', + privacy: opts.privacy, + env: sharedEnvTemplateToken, + })}.${templateExtension}` + : undefined; + + const candidates: string[] = []; + for (const dir of pathsOrdered) { const targetPath = path.resolve(dir, filename); + candidates.push(targetPath); if (await opts.fs.pathExists(targetPath)) { return { targetPath, filename }; } const templatePath = `${targetPath}.${templateExtension}`; + candidates.push(templatePath); if (await opts.fs.pathExists(templatePath)) { return { targetPath, templatePath, filename }; } } + if (sharedEnvTemplateFilename) { + for (const dir of pathsOrdered) { + const targetPath = path.resolve(dir, filename); + const sharedTemplatePath = path.resolve(dir, sharedEnvTemplateFilename); + candidates.push(sharedTemplatePath); + if (await opts.fs.pathExists(sharedTemplatePath)) { + return { targetPath, templatePath: sharedTemplatePath, filename }; + } + } + } + throw new Error( - `Unable to locate dotenv target "${filename}" under provided paths, and no template was found.`, + `Unable to locate dotenv target "${filename}" under provided paths, and no template was found. Checked: ${candidates.join(', ')}`, ); } diff --git a/src/dotenv/edit/types.ts b/src/dotenv/edit/types.ts index 47901b6b..2bfb1d82 100644 --- a/src/dotenv/edit/types.ts +++ b/src/dotenv/edit/types.ts @@ -366,6 +366,20 @@ export interface EditDotenvFileOptions extends DotenvEditOptions { * @defaultValue `'template'` */ templateExtension?: string; + /** + * Env name token substituted in place of the real env name when building the + * shared env template filename (`scope: 'env'` only). For example, with the + * default token `'env'`, the shared template for `.env..local` is + * `.env.env.local.template`, and for `.env.` it is `.env.env.template`. + * + * The shared env template is used as a last-resort fallback when neither the + * env-scoped target nor its env-specific template (`.`) + * exists anywhere under `paths`. An env-specific template always takes priority + * over the shared one. This option has no effect for `scope: 'global'`. + * + * @defaultValue `'env'` + */ + sharedEnvTemplateToken?: string; /** * Optional filesystem port override (defaults to a Node FS adapter). */