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/).
|
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)
|
## 0.0.3 (2026-01-13)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -61,11 +61,16 @@ Commands:
|
|||||||
- `format --file <path> [--mode sections|global] [--sort alpha|none] [--write]`
|
- `format --file <path> [--mode sections|global] [--sort alpha|none] [--write]`
|
||||||
- `reorder --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]`
|
- `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:
|
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.
|
- 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
|
### List keys
|
||||||
|
|
||||||
@@ -217,6 +222,38 @@ envsitter format --file .env --mode sections --sort alpha --write
|
|||||||
envsitter reorder --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)
|
## Output contract (for LLMs)
|
||||||
|
|
||||||
General rules:
|
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<...> }`
|
- `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<...> }`
|
- `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": { ... } }`
|
- `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
|
## Library API
|
||||||
|
|
||||||
@@ -259,7 +298,16 @@ const match = await es.matchCandidate('OPENAI_API_KEY', 'candidate-secret');
|
|||||||
### File operations via the library
|
### File operations via the library
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { annotateEnvFile, copyEnvFileKeys, formatEnvFile, validateEnvFile } from 'envsitter';
|
import {
|
||||||
|
addEnvFileKey,
|
||||||
|
annotateEnvFile,
|
||||||
|
copyEnvFileKeys,
|
||||||
|
deleteEnvFileKeys,
|
||||||
|
formatEnvFile,
|
||||||
|
setEnvFileKey,
|
||||||
|
unsetEnvFileKey,
|
||||||
|
validateEnvFile
|
||||||
|
} from 'envsitter';
|
||||||
|
|
||||||
await validateEnvFile('.env');
|
await validateEnvFile('.env');
|
||||||
|
|
||||||
@@ -273,6 +321,18 @@ await copyEnvFileKeys({
|
|||||||
|
|
||||||
await annotateEnvFile({ file: '.env', key: 'DATABASE_URL', comment: 'prod only', write: true });
|
await annotateEnvFile({ file: '.env', key: 'DATABASE_URL', comment: 'prod only', write: true });
|
||||||
await formatEnvFile({ file: '.env', mode: 'sections', sort: 'alpha', 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
|
### Match operators via the library
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "envsitter",
|
"name": "envsitter",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Safely inspect and match .env secrets without exposing values",
|
"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
|
#!/usr/bin/env node
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
|
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 { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
|
||||||
|
import { isExampleEnvFile } from './dotenv/utils.js';
|
||||||
|
|
||||||
|
|
||||||
function parseRegex(input: string): RegExp {
|
function parseRegex(input: string): RegExp {
|
||||||
@@ -104,6 +105,12 @@ function jsonOut(value: unknown): void {
|
|||||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
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 {
|
function printHelp(): void {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
[
|
[
|
||||||
@@ -120,13 +127,18 @@ function printHelp(): void {
|
|||||||
' format --file <path> [--mode sections|global] [--sort alpha|none] [--write]',
|
' format --file <path> [--mode sections|global] [--sort alpha|none] [--write]',
|
||||||
' reorder --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]',
|
' 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 options:',
|
||||||
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
|
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
|
||||||
'',
|
'',
|
||||||
'Notes:',
|
'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',
|
' 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')
|
].join('\n')
|
||||||
);
|
);
|
||||||
@@ -273,6 +285,106 @@ async function run(): Promise<number> {
|
|||||||
return result.issues.length > 0 ? 2 : 0;
|
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 file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
|
||||||
const pepper = getPepperOptions(flags);
|
const pepper = getPepperOptions(flags);
|
||||||
const envsitter = EnvSitter.fromDotenvFile(file);
|
const envsitter = EnvSitter.fromDotenvFile(file);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
|
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
|
||||||
|
import { buildAssignmentLine } from './utils.js';
|
||||||
|
|
||||||
export type DotenvWriteMode = 'dry-run' | 'write';
|
export type DotenvWriteMode = 'dry-run' | 'write';
|
||||||
|
|
||||||
@@ -312,3 +313,219 @@ export function validateDotenv(contents: string): ValidateDotenvResult {
|
|||||||
const doc = parseDotenvDocument(contents);
|
const doc = parseDotenvDocument(contents);
|
||||||
return { issues: doc.issues, ok: doc.issues.length === 0 };
|
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 { 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';
|
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
|
||||||
|
|
||||||
export type DotenvIssue = {
|
export type DotenvIssue = {
|
||||||
@@ -172,3 +183,143 @@ export async function validateEnvFile(file: string): Promise<ValidateEnvFileResu
|
|||||||
const result = validateDotenv(contents);
|
const result = validateDotenv(contents);
|
||||||
return { file, ok: result.ok, issues: result.issues };
|
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 { type PepperOptions, resolvePepper } from './pepper.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
addEnvFileKey,
|
||||||
annotateEnvFile,
|
annotateEnvFile,
|
||||||
copyEnvFileKeys,
|
copyEnvFileKeys,
|
||||||
|
deleteEnvFileKeys,
|
||||||
formatEnvFile,
|
formatEnvFile,
|
||||||
|
setEnvFileKey,
|
||||||
|
unsetEnvFileKey,
|
||||||
validateEnvFile,
|
validateEnvFile,
|
||||||
|
type AddEnvFileKeyResult,
|
||||||
type AnnotateEnvFileResult,
|
type AnnotateEnvFileResult,
|
||||||
type CopyEnvFilesResult,
|
type CopyEnvFilesResult,
|
||||||
|
type DeleteEnvFileKeysResult,
|
||||||
type FormatEnvFileResult,
|
type FormatEnvFileResult,
|
||||||
|
type KeyMutationAction,
|
||||||
|
type KeyMutationPlanItem,
|
||||||
|
type SetEnvFileKeyResult,
|
||||||
|
type UnsetEnvFileKeyResult,
|
||||||
type ValidateEnvFileResult
|
type ValidateEnvFileResult
|
||||||
} from './file-ops.js';
|
} 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 { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
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> {
|
async function makeTempFile(fileName: string, contents: string): Promise<string> {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'envsitter-ops-'));
|
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');
|
const expected = ['# section one', 'A=1', 'B=2', '', '# section two', 'Y=8', 'Z=9', ''].join('\n');
|
||||||
assert.equal(out, expected);
|
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