Add key mutation commands (add, set, unset, delete) for v0.0.4

- Add CLI commands: add, set, unset, delete for modifying .env files
- Add value auto-quoting for special characters (spaces, #, quotes, newlines)
- Add example file detection with warning for .env.example/.sample/.template
- Add library API: addEnvFileKey, setEnvFileKey, unsetEnvFileKey, deleteEnvFileKeys
- Export isExampleEnvFile utility
- Add tests for new mutation operations
- Update README and CHANGELOG
This commit is contained in:
David Ibia
2026-01-15 21:29:06 +01:00
parent d9d3a41b0f
commit a64cc5cdf6
9 changed files with 718 additions and 7 deletions

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises';
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
import { annotateDotenvKey, copyDotenvKeys, formatDotenv, validateDotenv } from './dotenv/edit.js';
import { addDotenvKey, annotateDotenvKey, copyDotenvKeys, deleteDotenvKeys, formatDotenv, setDotenvKey, unsetDotenvKey, validateDotenv } from './dotenv/edit.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
import { isExampleEnvFile } from './dotenv/utils.js';
function parseRegex(input: string): RegExp {
@@ -104,6 +105,12 @@ function jsonOut(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function warnIfExampleFile(file: string, noWarn: boolean): void {
if (!noWarn && isExampleEnvFile(file)) {
process.stderr.write(`Warning: ${file} appears to be an example/template file. Use --no-example-warning to suppress.\n`);
}
}
function printHelp(): void {
process.stdout.write(
[
@@ -120,13 +127,18 @@ function printHelp(): void {
' format --file <path> [--mode sections|global] [--sort alpha|none] [--write]',
' reorder --file <path> [--mode sections|global] [--sort alpha|none] [--write]',
' annotate --file <path> --key <KEY> --comment <text> [--line <n>] [--write]',
' add --file <path> --key <KEY> [--value <v> | --value-stdin] [--write]',
' set --file <path> --key <KEY> [--value <v> | --value-stdin] [--write]',
' unset --file <path> --key <KEY> [--write]',
' delete --file <path> (--key <KEY> | --keys <K1,K2>) [--write]',
'',
'Pepper options:',
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
'',
'Notes:',
' match --op defaults to is_equal. Ops: exists,is_empty,is_equal,partial_match_regex,partial_match_prefix,partial_match_suffix,is_number,is_string,is_boolean',
' Candidate values passed via argv may end up in shell history. Prefer --candidate-stdin.',
' Values passed via argv may end up in shell history. Prefer --value-stdin or --candidate-stdin.',
' Mutation commands (add, set, unset, delete) are dry-run unless --write is provided.',
''
].join('\n')
);
@@ -273,6 +285,106 @@ async function run(): Promise<number> {
return result.issues.length > 0 ? 2 : 0;
}
if (cmd === 'add') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const key = requireValue(typeof flags['key'] === 'string' ? flags['key'] : undefined, '--key is required');
const noExampleWarning = flags['no-example-warning'] === true;
warnIfExampleFile(file, noExampleWarning);
const valueArg = typeof flags['value'] === 'string' ? flags['value'] : undefined;
const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
const value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file);
const result = addDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
}
return result.plan.action === 'key_exists' ? 2 : 0;
}
if (cmd === 'set') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const key = requireValue(typeof flags['key'] === 'string' ? flags['key'] : undefined, '--key is required');
const noExampleWarning = flags['no-example-warning'] === true;
warnIfExampleFile(file, noExampleWarning);
const valueArg = typeof flags['value'] === 'string' ? flags['value'] : undefined;
const valueStdin = flags['value-stdin'] === true ? (await readStdinText()).trimEnd() : undefined;
const value = valueStdin ?? valueArg ?? '';
const contents = await readTextFileOrEmpty(file);
const result = setDotenvKey({ contents, key, value });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
}
return 0;
}
if (cmd === 'unset') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const key = requireValue(typeof flags['key'] === 'string' ? flags['key'] : undefined, '--key is required');
const noExampleWarning = flags['no-example-warning'] === true;
warnIfExampleFile(file, noExampleWarning);
const contents = await readFile(file, 'utf8');
const result = unsetDotenvKey({ contents, key });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) {
jsonOut({ file, key, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}${result.plan.line ? ` L${result.plan.line}` : ''}\n`);
}
return result.plan.action === 'not_found' ? 2 : 0;
}
if (cmd === 'delete') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const noExampleWarning = flags['no-example-warning'] === true;
warnIfExampleFile(file, noExampleWarning);
const keyArg = typeof flags['key'] === 'string' ? flags['key'] : undefined;
const keysArg = typeof flags['keys'] === 'string' ? flags['keys'] : undefined;
const keys = keyArg ? [keyArg] : keysArg ? parseList(keysArg) : undefined;
if (!keys || keys.length === 0) throw new Error('Provide --key or --keys');
const contents = await readFile(file, 'utf8');
const result = deleteDotenvKeys({ contents, keys });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) {
jsonOut({ file, keys, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
} else {
for (const p of result.plan) {
process.stdout.write(`${p.action}: ${p.key}${p.line ? ` L${p.line}` : ''}\n`);
}
}
const allNotFound = result.plan.every((p) => p.action === 'not_found');
return allNotFound ? 2 : 0;
}
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const pepper = getPepperOptions(flags);
const envsitter = EnvSitter.fromDotenvFile(file);

View File

@@ -1,4 +1,5 @@
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
import { buildAssignmentLine } from './utils.js';
export type DotenvWriteMode = 'dry-run' | 'write';
@@ -312,3 +313,219 @@ export function validateDotenv(contents: string): ValidateDotenvResult {
const doc = parseDotenvDocument(contents);
return { issues: doc.issues, ok: doc.issues.length === 0 };
}
export type KeyMutationAction = 'added' | 'updated' | 'unset' | 'deleted' | 'key_exists' | 'not_found' | 'no_change';
export type KeyMutationPlanItem = {
key: string;
action: KeyMutationAction;
line?: number;
};
export type AddDotenvKeyResult = {
output: string;
issues: DotenvIssue[];
plan: KeyMutationPlanItem;
hasChanges: boolean;
};
export function addDotenvKey(options: { contents: string; key: string; value: string }): AddDotenvKeyResult {
const doc = parseDotenvDocument(options.contents);
const issues: DotenvIssue[] = [...doc.issues];
const assignments = listAssignments(doc.lines);
const existing = lastAssignmentForKey(assignments, options.key);
if (existing) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'key_exists', line: existing.line },
hasChanges: false
};
}
const newLine = buildAssignmentLine(options.key, options.value);
const lastLine = doc.lines.length > 0 ? Math.max(...doc.lines.map((l) => l.line)) : 0;
doc.lines.push({
kind: 'assignment',
line: lastLine + 1,
raw: newLine,
leadingWhitespace: '',
exported: false,
key: options.key,
keyColumn: 1,
beforeEqWhitespace: '',
afterEqRaw: newLine.slice(options.key.length + 1),
quote: 'none',
value: options.value
});
return {
output: stringifyDotenvDocument(doc),
issues,
plan: { key: options.key, action: 'added', line: lastLine + 1 },
hasChanges: true
};
}
export type SetDotenvKeyResult = {
output: string;
issues: DotenvIssue[];
plan: KeyMutationPlanItem;
hasChanges: boolean;
};
export function setDotenvKey(options: { contents: string; key: string; value: string }): SetDotenvKeyResult {
const doc = parseDotenvDocument(options.contents);
const issues: DotenvIssue[] = [...doc.issues];
const assignments = listAssignments(doc.lines);
const existing = lastAssignmentForKey(assignments, options.key);
if (!existing) {
const newLine = buildAssignmentLine(options.key, options.value);
const lastLine = doc.lines.length > 0 ? Math.max(...doc.lines.map((l) => l.line)) : 0;
doc.lines.push({
kind: 'assignment',
line: lastLine + 1,
raw: newLine,
leadingWhitespace: '',
exported: false,
key: options.key,
keyColumn: 1,
beforeEqWhitespace: '',
afterEqRaw: newLine.slice(options.key.length + 1),
quote: 'none',
value: options.value
});
return {
output: stringifyDotenvDocument(doc),
issues,
plan: { key: options.key, action: 'added', line: lastLine + 1 },
hasChanges: true
};
}
if (existing.value === options.value) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'no_change', line: existing.line },
hasChanges: false
};
}
const newRaw = `${existing.leadingWhitespace}${existing.exported ? 'export ' : ''}${options.key}${existing.beforeEqWhitespace}=${buildAssignmentLine('', options.value).slice(1)}`;
for (let i = doc.lines.length - 1; i >= 0; i--) {
const l = doc.lines[i];
if (l?.kind === 'assignment' && l.key === options.key && l.line === existing.line) {
doc.lines[i] = { ...l, raw: newRaw, value: options.value, afterEqRaw: newRaw.slice(newRaw.indexOf('=') + 1) };
break;
}
}
return {
output: stringifyDotenvDocument(doc),
issues,
plan: { key: options.key, action: 'updated', line: existing.line },
hasChanges: true
};
}
export type UnsetDotenvKeyResult = {
output: string;
issues: DotenvIssue[];
plan: KeyMutationPlanItem;
hasChanges: boolean;
};
export function unsetDotenvKey(options: { contents: string; key: string }): UnsetDotenvKeyResult {
const doc = parseDotenvDocument(options.contents);
const issues: DotenvIssue[] = [...doc.issues];
const assignments = listAssignments(doc.lines);
const existing = lastAssignmentForKey(assignments, options.key);
if (!existing) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'not_found' },
hasChanges: false
};
}
if (existing.value === '') {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'no_change', line: existing.line },
hasChanges: false
};
}
const newRaw = `${existing.leadingWhitespace}${existing.exported ? 'export ' : ''}${options.key}${existing.beforeEqWhitespace}=`;
for (let i = doc.lines.length - 1; i >= 0; i--) {
const l = doc.lines[i];
if (l?.kind === 'assignment' && l.key === options.key && l.line === existing.line) {
doc.lines[i] = { ...l, raw: newRaw, value: '', afterEqRaw: '', quote: 'none' };
break;
}
}
return {
output: stringifyDotenvDocument(doc),
issues,
plan: { key: options.key, action: 'unset', line: existing.line },
hasChanges: true
};
}
export type DeleteDotenvKeysResult = {
output: string;
issues: DotenvIssue[];
plan: KeyMutationPlanItem[];
hasChanges: boolean;
};
export function deleteDotenvKeys(options: { contents: string; keys: readonly string[] }): DeleteDotenvKeysResult {
const doc = parseDotenvDocument(options.contents);
const issues: DotenvIssue[] = [...doc.issues];
const assignments = listAssignments(doc.lines);
const plan: KeyMutationPlanItem[] = [];
const keysToDelete = new Set<string>();
const linesToDelete = new Set<number>();
for (const key of options.keys) {
const existing = lastAssignmentForKey(assignments, key);
if (!existing) {
plan.push({ key, action: 'not_found' });
continue;
}
keysToDelete.add(key);
linesToDelete.add(existing.line);
plan.push({ key, action: 'deleted', line: existing.line });
}
if (linesToDelete.size === 0) {
return { output: options.contents, issues, plan, hasChanges: false };
}
doc.lines = doc.lines.filter((l) => {
if (l.kind !== 'assignment') return true;
return !linesToDelete.has(l.line);
});
return {
output: stringifyDotenvDocument(doc),
issues,
plan,
hasChanges: true
};
}

31
src/dotenv/utils.ts Normal file
View File

@@ -0,0 +1,31 @@
const EXAMPLE_FILE_PATTERN = /\.env\.(example|sample|template|dist|defaults?)$/i;
export function isExampleEnvFile(filePath: string): boolean {
return EXAMPLE_FILE_PATTERN.test(filePath);
}
export function quoteValue(value: string): string {
if (value === '') return '';
const hasWhitespace = /\s/.test(value);
const hasSpecialChars = /[#"'\\$`]/.test(value);
const hasControlChars = /[\n\r\t]/.test(value);
const hasEdgeSpaces = value.startsWith(' ') || value.endsWith(' ');
const needsQuoting = hasWhitespace || hasSpecialChars || hasControlChars || hasEdgeSpaces;
if (!needsQuoting) return value;
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
return `"${escaped}"`;
}
export function buildAssignmentLine(key: string, value: string): string {
return `${key}=${quoteValue(value)}`;
}

View File

@@ -1,6 +1,17 @@
import { readFile } from 'node:fs/promises';
import { annotateDotenvKey, copyDotenvKeys, formatDotenv, validateDotenv } from './dotenv/edit.js';
import {
addDotenvKey,
annotateDotenvKey,
copyDotenvKeys,
deleteDotenvKeys,
formatDotenv,
setDotenvKey,
unsetDotenvKey,
validateDotenv,
type KeyMutationAction,
type KeyMutationPlanItem
} from './dotenv/edit.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
export type DotenvIssue = {
@@ -172,3 +183,143 @@ export async function validateEnvFile(file: string): Promise<ValidateEnvFileResu
const result = validateDotenv(contents);
return { file, ok: result.ok, issues: result.issues };
}
export type { KeyMutationAction, KeyMutationPlanItem };
export type AddEnvFileKeyResult = {
file: string;
key: string;
willWrite: boolean;
wrote: boolean;
hasChanges: boolean;
issues: DotenvIssue[];
plan: KeyMutationPlanItem;
};
export async function addEnvFileKey(options: {
file: string;
key: string;
value: string;
write?: boolean;
}): Promise<AddEnvFileKeyResult> {
const contents = await readTextFileOrEmpty(options.file);
const result = addDotenvKey({ contents, key: options.key, value: options.value });
const willWrite = options.write === true;
if (willWrite && result.hasChanges) {
await writeTextFileAtomic(options.file, result.output);
}
return {
file: options.file,
key: options.key,
willWrite,
wrote: willWrite && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
};
}
export type SetEnvFileKeyResult = {
file: string;
key: string;
willWrite: boolean;
wrote: boolean;
hasChanges: boolean;
issues: DotenvIssue[];
plan: KeyMutationPlanItem;
};
export async function setEnvFileKey(options: {
file: string;
key: string;
value: string;
write?: boolean;
}): Promise<SetEnvFileKeyResult> {
const contents = await readTextFileOrEmpty(options.file);
const result = setDotenvKey({ contents, key: options.key, value: options.value });
const willWrite = options.write === true;
if (willWrite && result.hasChanges) {
await writeTextFileAtomic(options.file, result.output);
}
return {
file: options.file,
key: options.key,
willWrite,
wrote: willWrite && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
};
}
export type UnsetEnvFileKeyResult = {
file: string;
key: string;
willWrite: boolean;
wrote: boolean;
hasChanges: boolean;
issues: DotenvIssue[];
plan: KeyMutationPlanItem;
};
export async function unsetEnvFileKey(options: {
file: string;
key: string;
write?: boolean;
}): Promise<UnsetEnvFileKeyResult> {
const contents = await readFile(options.file, 'utf8');
const result = unsetDotenvKey({ contents, key: options.key });
const willWrite = options.write === true;
if (willWrite && result.hasChanges) {
await writeTextFileAtomic(options.file, result.output);
}
return {
file: options.file,
key: options.key,
willWrite,
wrote: willWrite && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
};
}
export type DeleteEnvFileKeysResult = {
file: string;
keys: string[];
willWrite: boolean;
wrote: boolean;
hasChanges: boolean;
issues: DotenvIssue[];
plan: KeyMutationPlanItem[];
};
export async function deleteEnvFileKeys(options: {
file: string;
keys: readonly string[];
write?: boolean;
}): Promise<DeleteEnvFileKeysResult> {
const contents = await readFile(options.file, 'utf8');
const result = deleteDotenvKeys({ contents, keys: options.keys });
const willWrite = options.write === true;
if (willWrite && result.hasChanges) {
await writeTextFileAtomic(options.file, result.output);
}
return {
file: options.file,
keys: [...options.keys],
willWrite,
wrote: willWrite && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
};
}

View File

@@ -13,12 +13,24 @@ export {
export { type PepperOptions, resolvePepper } from './pepper.js';
export {
addEnvFileKey,
annotateEnvFile,
copyEnvFileKeys,
deleteEnvFileKeys,
formatEnvFile,
setEnvFileKey,
unsetEnvFileKey,
validateEnvFile,
type AddEnvFileKeyResult,
type AnnotateEnvFileResult,
type CopyEnvFilesResult,
type DeleteEnvFileKeysResult,
type FormatEnvFileResult,
type KeyMutationAction,
type KeyMutationPlanItem,
type SetEnvFileKeyResult,
type UnsetEnvFileKeyResult,
type ValidateEnvFileResult
} from './file-ops.js';
export { isExampleEnvFile } from './dotenv/utils.js';

View File

@@ -4,7 +4,7 @@ import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { annotateEnvFile, copyEnvFileKeys, formatEnvFile, validateEnvFile } from '../index.js';
import { addEnvFileKey, annotateEnvFile, copyEnvFileKeys, deleteEnvFileKeys, formatEnvFile, isExampleEnvFile, setEnvFileKey, unsetEnvFileKey, validateEnvFile } from '../index.js';
async function makeTempFile(fileName: string, contents: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-ops-'));
@@ -75,3 +75,110 @@ test('formatEnvFile sorts assignments within sections', async () => {
const expected = ['# section one', 'A=1', 'B=2', '', '# section two', 'Y=8', 'Z=9', ''].join('\n');
assert.equal(out, expected);
});
test('addEnvFileKey adds new key and fails if key exists', async () => {
const file = await makeTempFile('.env', 'EXISTING=value\n');
const addNew = await addEnvFileKey({ file, key: 'NEW_KEY', value: 'new_value', write: true });
assert.equal(addNew.wrote, true);
assert.equal(addNew.plan.action, 'added');
const contents = await readFile(file, 'utf8');
assert.ok(contents.includes('NEW_KEY=new_value'));
const addExisting = await addEnvFileKey({ file, key: 'EXISTING', value: 'other', write: true });
assert.equal(addExisting.wrote, false);
assert.equal(addExisting.plan.action, 'key_exists');
});
test('addEnvFileKey auto-quotes values with special characters', async () => {
const file = await makeTempFile('.env', '');
await addEnvFileKey({ file, key: 'SIMPLE', value: 'simple', write: true });
await addEnvFileKey({ file, key: 'WITH_SPACE', value: 'has space', write: true });
await addEnvFileKey({ file, key: 'WITH_HASH', value: 'before#after', write: true });
await addEnvFileKey({ file, key: 'WITH_NEWLINE', value: 'line1\nline2', write: true });
const contents = await readFile(file, 'utf8');
assert.ok(contents.includes('SIMPLE=simple'));
assert.ok(contents.includes('WITH_SPACE="has space"'));
assert.ok(contents.includes('WITH_HASH="before#after"'));
assert.ok(contents.includes('WITH_NEWLINE="line1\\nline2"'));
});
test('setEnvFileKey creates or updates key', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const setNew = await setEnvFileKey({ file, key: 'B', value: '2', write: true });
assert.equal(setNew.wrote, true);
assert.equal(setNew.plan.action, 'added');
const setExisting = await setEnvFileKey({ file, key: 'A', value: 'updated', write: true });
assert.equal(setExisting.wrote, true);
assert.equal(setExisting.plan.action, 'updated');
const contents = await readFile(file, 'utf8');
assert.ok(contents.includes('A=updated'));
assert.ok(contents.includes('B=2'));
const setSame = await setEnvFileKey({ file, key: 'A', value: 'updated', write: true });
assert.equal(setSame.wrote, false);
assert.equal(setSame.plan.action, 'no_change');
});
test('unsetEnvFileKey sets key to empty value', async () => {
const file = await makeTempFile('.env', 'A=value\nB=\n');
const unsetA = await unsetEnvFileKey({ file, key: 'A', write: true });
assert.equal(unsetA.wrote, true);
assert.equal(unsetA.plan.action, 'unset');
const contents = await readFile(file, 'utf8');
assert.ok(contents.includes('A=\n') || contents.includes('A='));
assert.ok(!contents.includes('A=value'));
const unsetB = await unsetEnvFileKey({ file, key: 'B', write: true });
assert.equal(unsetB.wrote, false);
assert.equal(unsetB.plan.action, 'no_change');
const unsetMissing = await unsetEnvFileKey({ file, key: 'MISSING', write: true });
assert.equal(unsetMissing.wrote, false);
assert.equal(unsetMissing.plan.action, 'not_found');
});
test('deleteEnvFileKeys removes keys from file', async () => {
const file = await makeTempFile('.env', 'A=1\nB=2\nC=3\n');
const deleteSingle = await deleteEnvFileKeys({ file, keys: ['B'], write: true });
assert.equal(deleteSingle.wrote, true);
assert.equal(deleteSingle.plan.length, 1);
assert.equal(deleteSingle.plan[0]?.action, 'deleted');
let contents = await readFile(file, 'utf8');
assert.ok(!contents.includes('B='));
assert.ok(contents.includes('A=1'));
assert.ok(contents.includes('C=3'));
const deleteMultiple = await deleteEnvFileKeys({ file, keys: ['A', 'C', 'MISSING'], write: true });
assert.equal(deleteMultiple.wrote, true);
assert.equal(deleteMultiple.plan.filter((p) => p.action === 'deleted').length, 2);
assert.equal(deleteMultiple.plan.filter((p) => p.action === 'not_found').length, 1);
contents = await readFile(file, 'utf8');
assert.ok(!contents.includes('A='));
assert.ok(!contents.includes('C='));
});
test('isExampleEnvFile detects example/template files', () => {
assert.equal(isExampleEnvFile('.env'), false);
assert.equal(isExampleEnvFile('.env.local'), false);
assert.equal(isExampleEnvFile('.env.production'), false);
assert.equal(isExampleEnvFile('.env.example'), true);
assert.equal(isExampleEnvFile('.env.sample'), true);
assert.equal(isExampleEnvFile('.env.template'), true);
assert.equal(isExampleEnvFile('.env.dist'), true);
assert.equal(isExampleEnvFile('.env.default'), true);
assert.equal(isExampleEnvFile('.env.defaults'), true);
assert.equal(isExampleEnvFile('/path/to/.env.example'), true);
assert.equal(isExampleEnvFile('.env.EXAMPLE'), true);
});