diff --git a/CHANGELOG.md b/CHANGELOG.md index d1cd7b2..da93638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project are documented in this file. This project follows [Semantic Versioning](https://semver.org/) and the format is loosely based on [Keep a Changelog](https://keepachangelog.com/). +## 0.0.4 (2026-01-15) + +### Added + +- Key mutation commands (CLI): `add`, `set`, `unset`, `delete`. + - `add`: Add a new key-value pair (fails if key already exists). + - `set`: Create or update a key-value pair (idempotent). + - `unset`: Set a key's value to empty (`KEY=`). + - `delete`: Remove key(s) from the file entirely (supports `--keys` for multiple). +- Value auto-quoting: Values with spaces, `#`, quotes, newlines, or other special characters are automatically double-quoted with proper escaping. +- Example file detection: Warns when targeting `.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.default(s)` files. Suppressible with `--no-example-warning`. +- Library API exports for key mutations: `addEnvFileKey`, `setEnvFileKey`, `unsetEnvFileKey`, `deleteEnvFileKeys`. +- Utility export: `isExampleEnvFile` for detecting example/template env files. +- Test coverage for new mutation operations. + +### Changed + +- Package version bumped to `0.0.4`. +- Updated CLI help text to include new commands and notes about dry-run behavior. +- Updated README with documentation for new commands and library API. + ## 0.0.3 (2026-01-13) ### Added diff --git a/README.md b/README.md index 39bb2a2..75df325 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,16 @@ Commands: - `format --file [--mode sections|global] [--sort alpha|none] [--write]` - `reorder --file [--mode sections|global] [--sort alpha|none] [--write]` - `annotate --file --key --comment [--line ] [--write]` +- `add --file --key [--value | --value-stdin] [--write]` +- `set --file --key [--value | --value-stdin] [--write]` +- `unset --file --key [--write]` +- `delete --file (--key | --keys ) [--write]` Notes for file operations: -- Commands that modify files (`copy`, `format`/`reorder`, `annotate`) are dry-run unless `--write` is provided. +- Commands that modify files (`copy`, `format`/`reorder`, `annotate`, `add`, `set`, `unset`, `delete`) are dry-run unless `--write` is provided. - These commands never print secret values; output includes keys, booleans, and line numbers only. +- When targeting example files (`.env.example`, `.env.sample`, `.env.template`), a warning is emitted. Use `--no-example-warning` to suppress. ### List keys @@ -217,6 +222,38 @@ envsitter format --file .env --mode sections --sort alpha --write envsitter reorder --file .env --mode sections --sort alpha --write ``` +### Add a new key (fails if key exists) + +```bash +envsitter add --file .env --key NEW_API_KEY --value "sk-xxx" --write +# or via stdin (recommended to avoid shell history): +node -e "process.stdout.write('sk-xxx')" | envsitter add --file .env --key NEW_API_KEY --value-stdin --write +``` + +### Set a key (creates or updates) + +```bash +envsitter set --file .env --key API_KEY --value "new-value" --write +# or via stdin: +node -e "process.stdout.write('new-value')" | envsitter set --file .env --key API_KEY --value-stdin --write +``` + +### Unset a key (set to empty value) + +```bash +envsitter unset --file .env --key OLD_KEY --write +``` + +### Delete keys + +```bash +# Single key: +envsitter delete --file .env --key DEPRECATED_KEY --write + +# Multiple keys: +envsitter delete --file .env --keys OLD_KEY,UNUSED_KEY,LEGACY_KEY --write +``` + ## Output contract (for LLMs) General rules: @@ -241,6 +278,8 @@ JSON outputs: - `copy --json` -> `{ "from": string, "to": string, "onConflict": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": Array<...> }` - `format --json` / `reorder --json` -> `{ "file": string, "mode": string, "sort": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...> }` - `annotate --json` -> `{ "file": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": { ... } }` +- `add --json` / `set --json` / `unset --json` -> `{ "file": string, "key": string, "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": { "key": string, "action": "added"|"updated"|"unset"|"key_exists"|"not_found"|"no_change", "line"?: number } }` +- `delete --json` -> `{ "file": string, "keys": string[], "willWrite": boolean, "wrote": boolean, "hasChanges": boolean, "issues": Array<...>, "plan": Array<{ "key": string, "action": "deleted"|"not_found", "line"?: number }> }` ## Library API @@ -259,7 +298,16 @@ const match = await es.matchCandidate('OPENAI_API_KEY', 'candidate-secret'); ### File operations via the library ```ts -import { annotateEnvFile, copyEnvFileKeys, formatEnvFile, validateEnvFile } from 'envsitter'; +import { + addEnvFileKey, + annotateEnvFile, + copyEnvFileKeys, + deleteEnvFileKeys, + formatEnvFile, + setEnvFileKey, + unsetEnvFileKey, + validateEnvFile +} from 'envsitter'; await validateEnvFile('.env'); @@ -273,6 +321,18 @@ await copyEnvFileKeys({ await annotateEnvFile({ file: '.env', key: 'DATABASE_URL', comment: 'prod only', write: true }); await formatEnvFile({ file: '.env', mode: 'sections', sort: 'alpha', write: true }); + +// Add a new key (fails if exists) +await addEnvFileKey({ file: '.env', key: 'NEW_KEY', value: 'new_value', write: true }); + +// Set a key (creates or updates) +await setEnvFileKey({ file: '.env', key: 'API_KEY', value: 'updated_value', write: true }); + +// Unset a key (set to empty) +await unsetEnvFileKey({ file: '.env', key: 'OLD_KEY', write: true }); + +// Delete keys +await deleteEnvFileKeys({ file: '.env', keys: ['DEPRECATED', 'UNUSED'], write: true }); ``` ### Match operators via the library diff --git a/package.json b/package.json index d7c0217..9b5dc35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "envsitter", - "version": "0.0.3", + "version": "0.0.4", "private": false, "type": "module", "description": "Safely inspect and match .env secrets without exposing values", diff --git a/src/cli.ts b/src/cli.ts index 9b574c9..ea7f3e8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 [--mode sections|global] [--sort alpha|none] [--write]', ' reorder --file [--mode sections|global] [--sort alpha|none] [--write]', ' annotate --file --key --comment [--line ] [--write]', + ' add --file --key [--value | --value-stdin] [--write]', + ' set --file --key [--value | --value-stdin] [--write]', + ' unset --file --key [--write]', + ' delete --file (--key | --keys ) [--write]', '', 'Pepper options:', ' --pepper-file 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 { 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); diff --git a/src/dotenv/edit.ts b/src/dotenv/edit.ts index d269db1..cf10e6d 100644 --- a/src/dotenv/edit.ts +++ b/src/dotenv/edit.ts @@ -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(); + const linesToDelete = new Set(); + + 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 + }; +} diff --git a/src/dotenv/utils.ts b/src/dotenv/utils.ts new file mode 100644 index 0000000..f45486d --- /dev/null +++ b/src/dotenv/utils.ts @@ -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)}`; +} diff --git a/src/file-ops.ts b/src/file-ops.ts index 2e9c155..54b9a31 100644 --- a/src/file-ops.ts +++ b/src/file-ops.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + }; +} diff --git a/src/index.ts b/src/index.ts index a678a43..be74354 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/test/dotenv-file-ops.test.ts b/src/test/dotenv-file-ops.test.ts index 882d191..0cdca91 100644 --- a/src/test/dotenv-file-ops.test.ts +++ b/src/test/dotenv-file-ops.test.ts @@ -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 { 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); +});