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:
21
CHANGELOG.md
21
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
|
||||
|
||||
64
README.md
64
README.md
@@ -61,11 +61,16 @@ Commands:
|
||||
- `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]`
|
||||
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
116
src/cli.ts
116
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 <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);
|
||||
|
||||
@@ -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
31
src/dotenv/utils.ts
Normal 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)}`;
|
||||
}
|
||||
153
src/file-ops.ts
153
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<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
|
||||
};
|
||||
}
|
||||
|
||||
12
src/index.ts
12
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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user