Add dotenv file operations

This commit is contained in:
David Ibia
2026-01-13 18:20:37 +01:00
parent 2b4ff5bf81
commit 46c7a34355
10 changed files with 1058 additions and 14 deletions

42
CHANGELOG.md Normal file
View File

@@ -0,0 +1,42 @@
# Changelog
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.3 (2026-01-13)
### Added
- Dotenv file operations (CLI): `validate`, `copy`, `format`/`reorder`, and `annotate`.
- Round-trippable dotenv parsing for file ops (preserves comments/blank lines) with issue reporting that includes line/column.
- Library API exports for file ops: `validateEnvFile`, `copyEnvFileKeys`, `formatEnvFile`, `annotateEnvFile`.
- Test coverage for file operations.
### Changed
- Package version bumped to `0.0.3`.
## 0.0.2 (2026-01-12)
### Added
- Match operators for boolean checks beyond strict equality:
- `exists`, `is_empty`, `is_equal`, `partial_match_prefix`, `partial_match_suffix`, `partial_match_regex`, `is_number`, `is_boolean`, `is_string`.
- CLI support for `match --op <op>`.
- Library support for `EnvSitter.matchKey()` / `EnvSitter.matchKeyBulk()` with matcher operators.
- More tests for matcher operators.
### Changed
- Expanded CLI docs and output contract guidance in `README.md`.
- Added a reference to `envsitter-guard` in documentation.
## 0.0.1
### Added
- Initial public release.
- CLI commands: `keys`, `fingerprint`, `match`, `match-by-key`, `scan`.
- Library API: `EnvSitter` with safe key listing, deterministic fingerprints (HMAC-SHA-256 + pepper), and outside-in matching.
- Support for dotenv sources via local file and external command.

View File

@@ -56,6 +56,16 @@ Commands:
- `match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]`
- `match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)`
- `scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]`
- `validate --file <path>`
- `copy --from <path> --to <path> [--keys <K1,K2>] [--include-regex <re>] [--exclude-regex <re>] [--rename <A=B,C=D>] [--on-conflict error|skip|overwrite] [--write]`
- `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]`
Notes for file operations:
- Commands that modify files (`copy`, `format`/`reorder`, `annotate`) are dry-run unless `--write` is provided.
- These commands never print secret values; output includes keys, booleans, and line numbers only.
### List keys
@@ -166,6 +176,47 @@ Optionally restrict which keys to scan:
envsitter scan --file .env --keys-regex "/(JWT|URL)/" --detect jwt,url
```
### Validate dotenv syntax
```bash
envsitter validate --file .env
envsitter validate --file .env --json
```
### Copy keys between env files (production → staging)
Dry-run (no file is modified):
```bash
envsitter copy --from .env.production --to .env.staging --keys API_URL,REDIS_URL --json
```
Apply changes:
```bash
envsitter copy --from .env.production --to .env.staging --keys API_URL,REDIS_URL --on-conflict overwrite --write --json
```
Rename while copying:
```bash
envsitter copy --from .env.production --to .env.staging --keys DATABASE_URL --rename DATABASE_URL=STAGING_DATABASE_URL --write
```
### Annotate keys with comments
```bash
envsitter annotate --file .env --key DATABASE_URL --comment "prod only" --write
```
### Reorder/format env files
```bash
envsitter format --file .env --mode sections --sort alpha --write
# alias:
envsitter reorder --file .env --mode sections --sort alpha --write
```
## Output contract (for LLMs)
General rules:
@@ -186,6 +237,10 @@ JSON outputs:
- with `--op`: `{ "op": string, "matches": Array<{ "key": string, "match": boolean }> }`
- `match-by-key --json` -> `{ "matches": Array<{ "key": string, "match": boolean }> }`
- `scan --json` -> `{ "findings": Array<{ "key": string, "detections": Array<"jwt"|"url"|"base64"> }> }`
- `validate --json` -> `{ "ok": boolean, "issues": Array<{ "line": number, "column": number, "message": string }> }`
- `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": { ... } }`
## Library API
@@ -201,6 +256,25 @@ const fp = await es.fingerprintKey('OPENAI_API_KEY');
const match = await es.matchCandidate('OPENAI_API_KEY', 'candidate-secret');
```
### File operations via the library
```ts
import { annotateEnvFile, copyEnvFileKeys, formatEnvFile, validateEnvFile } from 'envsitter';
await validateEnvFile('.env');
await copyEnvFileKeys({
from: '.env.production',
to: '.env.staging',
keys: ['API_URL', 'REDIS_URL'],
onConflict: 'overwrite',
write: true
});
await annotateEnvFile({ file: '.env', key: 'DATABASE_URL', comment: 'prod only', write: true });
await formatEnvFile({ file: '.env', mode: 'sections', sort: 'alpha', write: true });
```
### Match operators via the library
```ts

View File

@@ -1,9 +1,23 @@
{
"name": "envsitter",
"version": "0.0.2",
"version": "0.0.3",
"private": false,
"type": "module",
"description": "Safely inspect and match .env secrets without exposing values",
"keywords": [
"dotenv",
"env",
"secrets",
"secret",
"security",
"hmac",
"fingerprint",
"audit",
"cli",
"devops",
"configuration",
"config"
],
"license": "MIT",
"bin": {
"envsitter": "dist/cli.js"

View File

@@ -1,15 +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 { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
type PepperCliOptions = {
pepperFile?: string;
};
type CommonCliOptions = {
file?: string;
pepper?: PepperCliOptions;
json?: boolean;
};
function parseRegex(input: string): RegExp {
const trimmed = input.trim();
@@ -121,6 +115,11 @@ function printHelp(): void {
' match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]',
' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]',
' validate --file <path>',
' copy --from <path> --to <path> [--keys <K1,K2>] [--include-regex <re>] [--exclude-regex <re>] [--rename <A=B,C=D>] [--on-conflict error|skip|overwrite] [--write]',
' 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]',
'',
'Pepper options:',
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
@@ -154,6 +153,126 @@ async function run(): Promise<number> {
return 0;
}
const json = flags['json'] === true;
if (cmd === 'validate') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const contents = await readFile(file, 'utf8');
const result = validateDotenv(contents);
if (json) jsonOut(result);
else {
if (result.ok) process.stdout.write('OK\n');
else {
for (const issue of result.issues) {
process.stdout.write(`L${issue.line}:C${issue.column}: ${issue.message}\n`);
}
}
}
return result.ok ? 0 : 2;
}
if (cmd === 'copy') {
const from = requireValue(typeof flags['from'] === 'string' ? flags['from'] : undefined, '--from is required');
const to = requireValue(typeof flags['to'] === 'string' ? flags['to'] : undefined, '--to is required');
const onConflictRaw = typeof flags['on-conflict'] === 'string' ? flags['on-conflict'] : 'error';
const onConflict = onConflictRaw === 'skip' || onConflictRaw === 'overwrite' ? onConflictRaw : 'error';
const keysRaw = typeof flags['keys'] === 'string' ? flags['keys'] : undefined;
const includeRaw = typeof flags['include-regex'] === 'string' ? flags['include-regex'] : undefined;
const excludeRaw = typeof flags['exclude-regex'] === 'string' ? flags['exclude-regex'] : undefined;
const renameRaw = typeof flags['rename'] === 'string' ? flags['rename'] : undefined;
const sourceContents = await readFile(from, 'utf8');
const targetContents = await readTextFileOrEmpty(to);
const result = copyDotenvKeys({
sourceContents,
targetContents,
...(keysRaw ? { keys: parseList(keysRaw) } : {}),
...(includeRaw ? { include: parseRegex(includeRaw) } : {}),
...(excludeRaw ? { exclude: parseRegex(excludeRaw) } : {}),
...(renameRaw ? { rename: renameRaw } : {}),
onConflict
});
const conflicts = result.plan.filter((p) => p.action === 'conflict');
const willWrite = flags['write'] === true;
if (willWrite && conflicts.length === 0 && result.hasChanges) {
await writeTextFileAtomic(to, result.output);
}
if (json) {
jsonOut({
from,
to,
onConflict,
willWrite,
wrote: willWrite && conflicts.length === 0 && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
});
} else {
for (const p of result.plan) {
const fromAt = p.fromLine ? ` L${p.fromLine}` : '';
const toAt = p.toLine ? ` -> L${p.toLine}` : '';
process.stdout.write(`${p.action}: ${p.fromKey} -> ${p.toKey}${fromAt}${toAt}\n`);
}
if (conflicts.length > 0) process.stdout.write('Conflicts found. Use --on-conflict overwrite|skip or resolve manually.\n');
}
return conflicts.length > 0 ? 2 : 0;
}
if (cmd === 'format' || cmd === 'reorder') {
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const modeRaw = typeof flags['mode'] === 'string' ? flags['mode'] : 'sections';
const sortRaw = typeof flags['sort'] === 'string' ? flags['sort'] : 'alpha';
const mode = modeRaw === 'global' ? 'global' : 'sections';
const sort = sortRaw === 'none' ? 'none' : 'alpha';
const contents = await readFile(file, 'utf8');
const result = formatDotenv({ contents, mode, sort });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) {
jsonOut({ file, mode, sort, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues });
} else {
process.stdout.write(result.hasChanges ? 'CHANGED\n' : 'NO_CHANGES\n');
}
return result.issues.length > 0 ? 2 : 0;
}
if (cmd === 'annotate') {
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 comment = requireValue(typeof flags['comment'] === 'string' ? flags['comment'] : undefined, '--comment is required');
const lineRaw = typeof flags['line'] === 'string' ? flags['line'] : undefined;
const line = lineRaw ? Number(lineRaw) : undefined;
const contents = await readFile(file, 'utf8');
const result = annotateDotenvKey({ contents, key, comment, ...(line ? { line } : {}) });
const willWrite = flags['write'] === true;
if (willWrite && result.hasChanges) await writeTextFileAtomic(file, result.output);
if (json) {
jsonOut({ file, willWrite, wrote: willWrite && result.hasChanges, hasChanges: result.hasChanges, issues: result.issues, plan: result.plan });
} else {
process.stdout.write(`${result.plan.action}: ${result.plan.key}\n`);
}
return result.issues.length > 0 ? 2 : 0;
}
const file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
const pepper = getPepperOptions(flags);
const envsitter = EnvSitter.fromDotenvFile(file);
@@ -163,7 +282,7 @@ async function run(): Promise<number> {
const filter = filterRegexRaw ? parseRegex(filterRegexRaw) : undefined;
const keys = await envsitter.listKeys(filter ? { filter } : {});
if (flags['json'] === true) jsonOut({ keys });
if (json) jsonOut({ keys });
else process.stdout.write(`${keys.join('\n')}\n`);
return 0;
}
@@ -193,20 +312,20 @@ async function run(): Promise<number> {
if (key) {
const match = await envsitter.matchKey(key, matcher, pepperOptions);
if (flags['json'] === true) jsonOut(includeOp ? { key, op: matcher.op, match } : { key, match });
if (json) jsonOut(includeOp ? { key, op: matcher.op, match } : { key, match });
return match ? 0 : 1;
}
if (keysCsv) {
const keys = parseList(keysCsv);
const results = await envsitter.matchKeyBulk(keys, matcher, pepperOptions);
if (flags['json'] === true) jsonOut(includeOp ? { op: matcher.op, matches: results } : { matches: results });
if (json) jsonOut(includeOp ? { op: matcher.op, matches: results } : { matches: results });
return results.some((r) => r.match) ? 0 : 1;
}
if (allKeys) {
const results = await envsitter.matchKeyAll(matcher, pepperOptions);
if (flags['json'] === true) jsonOut(includeOp ? { op: matcher.op, matches: results } : { matches: results });
if (json) jsonOut(includeOp ? { op: matcher.op, matches: results } : { matches: results });
return results.some((r) => r.match) ? 0 : 1;
}

195
src/dotenv/document.ts Normal file
View File

@@ -0,0 +1,195 @@
export type DotenvIssue = {
line: number;
column: number;
message: string;
};
export type DotenvParsedAssignment = {
line: number;
raw: string;
leadingWhitespace: string;
exported: boolean;
key: string;
keyColumn: number;
beforeEqWhitespace: string;
afterEqRaw: string;
quote: 'none' | 'single' | 'double';
value: string;
};
export type DotenvLine =
| { kind: 'blank'; line: number; raw: string }
| { kind: 'comment'; line: number; raw: string }
| ({ kind: 'assignment' } & DotenvParsedAssignment)
| { kind: 'unknown'; line: number; raw: string };
export type DotenvDocument = {
lines: DotenvLine[];
issues: DotenvIssue[];
endsWithNewline: boolean;
};
function isWhitespace(char: string): boolean {
return /\s/.test(char);
}
function isValidKeyChar(char: string): boolean {
return /[A-Za-z0-9_]/.test(char);
}
function stripInlineComment(unquotedValue: string): string {
for (let i = 0; i < unquotedValue.length; i++) {
const c = unquotedValue[i];
if (c === '#') {
const prev = i > 0 ? (unquotedValue[i - 1] ?? '') : '';
if (prev === '' || /\s/.test(prev)) return unquotedValue.slice(0, i);
}
}
return unquotedValue;
}
function unescapeDoubleQuoted(value: string): string {
let out = '';
for (let i = 0; i < value.length; i++) {
const c = value[i];
if (c !== '\\') {
out += c;
continue;
}
const next = value[i + 1];
if (next === undefined) {
out += '\\';
continue;
}
i++;
if (next === 'n') out += '\n';
else if (next === 'r') out += '\r';
else if (next === 't') out += '\t';
else out += next;
}
return out;
}
function parseValueDetailed(afterEqRaw: string, line: number, issues: DotenvIssue[]): { value: string; quote: 'none' | 'single' | 'double' } {
const trimmed = afterEqRaw.trimStart();
if (!trimmed) return { value: '', quote: 'none' };
const first = trimmed[0];
if (first === "'") {
const end = trimmed.indexOf("'", 1);
if (end === -1) {
issues.push({ line, column: 1, message: 'Unterminated single-quoted value' });
return { value: trimmed.slice(1), quote: 'single' };
}
return { value: trimmed.slice(1, end), quote: 'single' };
}
if (first === '"') {
let end = 1;
for (; end < trimmed.length; end++) {
const c = trimmed[end];
if (c === '"' && trimmed[end - 1] !== '\\') break;
}
if (end >= trimmed.length || trimmed[end] !== '"') {
issues.push({ line, column: 1, message: 'Unterminated double-quoted value' });
return { value: unescapeDoubleQuoted(trimmed.slice(1)), quote: 'double' };
}
return { value: unescapeDoubleQuoted(trimmed.slice(1, end)), quote: 'double' };
}
return { value: stripInlineComment(trimmed).trimEnd(), quote: 'none' };
}
function parseAssignmentLine(raw: string, line: number, issues: DotenvIssue[]): DotenvParsedAssignment | undefined {
let cursor = 0;
while (cursor < raw.length && isWhitespace(raw[cursor] ?? '')) cursor++;
const leadingWhitespace = raw.slice(0, cursor);
let exported = false;
const exportStart = cursor;
if (raw.slice(cursor).startsWith('export')) {
const afterExport = raw[cursor + 'export'.length] ?? '';
if (isWhitespace(afterExport)) {
exported = true;
cursor += 'export'.length;
while (cursor < raw.length && isWhitespace(raw[cursor] ?? '')) cursor++;
} else {
cursor = exportStart;
}
}
const keyStart = cursor;
while (cursor < raw.length && isValidKeyChar(raw[cursor] ?? '')) cursor++;
const key = raw.slice(keyStart, cursor);
if (!key) {
issues.push({ line, column: keyStart + 1, message: 'Invalid key name' });
return undefined;
}
const next = raw[cursor] ?? '';
if (next && !isWhitespace(next) && next !== '=') {
issues.push({ line, column: cursor + 1, message: 'Invalid key name' });
return undefined;
}
const wsStart = cursor;
while (cursor < raw.length && isWhitespace(raw[cursor] ?? '')) cursor++;
const beforeEqWhitespace = raw.slice(wsStart, cursor);
if ((raw[cursor] ?? '') !== '=') {
issues.push({ line, column: cursor + 1, message: 'Missing = in assignment' });
return undefined;
}
const afterEqRaw = raw.slice(cursor + 1);
const { value, quote } = parseValueDetailed(afterEqRaw, line, issues);
return {
line,
raw,
leadingWhitespace,
exported,
key,
keyColumn: keyStart + 1,
beforeEqWhitespace,
afterEqRaw,
value,
quote
};
}
export function parseDotenvDocument(contents: string): DotenvDocument {
const issues: DotenvIssue[] = [];
const endsWithNewline = contents.endsWith('\n');
const split = contents.split(/\r?\n/);
if (endsWithNewline) split.pop();
const lines: DotenvLine[] = [];
for (let i = 0; i < split.length; i++) {
const lineNumber = i + 1;
const raw = split[i] ?? '';
const trimmed = raw.trim();
if (!trimmed) {
lines.push({ kind: 'blank', line: lineNumber, raw });
continue;
}
if (raw.trimStart().startsWith('#')) {
lines.push({ kind: 'comment', line: lineNumber, raw });
continue;
}
const parsed = parseAssignmentLine(raw, lineNumber, issues);
if (parsed) lines.push({ kind: 'assignment', ...parsed });
else lines.push({ kind: 'unknown', line: lineNumber, raw });
}
return { lines, issues, endsWithNewline };
}
export function stringifyDotenvDocument(doc: DotenvDocument): string {
const out = doc.lines.map((l) => l.raw).join('\n');
return doc.endsWithNewline ? `${out}\n` : out;
}

314
src/dotenv/edit.ts Normal file
View File

@@ -0,0 +1,314 @@
import { parseDotenvDocument, stringifyDotenvDocument, type DotenvParsedAssignment, type DotenvIssue } from './document.js';
export type DotenvWriteMode = 'dry-run' | 'write';
export type CopyConflictPolicy = 'error' | 'skip' | 'overwrite';
export type CopyPlanItem = {
fromKey: string;
toKey: string;
action: 'copy' | 'skip' | 'overwrite' | 'missing_source' | 'conflict';
fromLine?: number;
toLine?: number;
};
export type CopyDotenvResult = {
output: string;
issues: DotenvIssue[];
plan: CopyPlanItem[];
hasChanges: boolean;
};
function lastAssignmentForKey(lines: readonly DotenvParsedAssignment[], key: string): DotenvParsedAssignment | undefined {
let last: DotenvParsedAssignment | undefined;
for (const l of lines) {
if (l.key === key) last = l;
}
return last;
}
function listAssignments(docLines: ReturnType<typeof parseDotenvDocument>['lines']): DotenvParsedAssignment[] {
const out: DotenvParsedAssignment[] = [];
for (const l of docLines) {
if (l.kind === 'assignment') out.push(l);
}
return out;
}
function parseRenameMap(raw: string | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!raw) return map;
for (const part of raw.split(',')) {
const trimmed = part.trim();
if (!trimmed) continue;
const [from, to] = trimmed.split('=', 2);
const fromKey = from?.trim();
const toKey = to?.trim();
if (!fromKey || !toKey) continue;
map.set(fromKey, toKey);
}
return map;
}
function withNewKey(source: DotenvParsedAssignment, newKey: string): string {
const exportPrefix = source.exported ? 'export ' : '';
return `${source.leadingWhitespace}${exportPrefix}${newKey}${source.beforeEqWhitespace}=${source.afterEqRaw}`;
}
export function copyDotenvKeys(options: {
sourceContents: string;
targetContents: string;
keys?: readonly string[];
include?: RegExp;
exclude?: RegExp;
rename?: string;
onConflict: CopyConflictPolicy;
}): CopyDotenvResult {
const sourceDoc = parseDotenvDocument(options.sourceContents);
const targetDoc = parseDotenvDocument(options.targetContents);
const issues: DotenvIssue[] = [...sourceDoc.issues.map((i) => ({ ...i, message: `source: ${i.message}` })), ...targetDoc.issues.map((i) => ({ ...i, message: `target: ${i.message}` }))];
const sourceAssignments = listAssignments(sourceDoc.lines);
const targetAssignments = listAssignments(targetDoc.lines);
const renameMap = parseRenameMap(options.rename);
const requestedKeys = new Set<string>();
if (options.keys) {
for (const k of options.keys) {
const trimmed = k.trim();
if (trimmed) requestedKeys.add(trimmed);
}
} else {
for (const a of sourceAssignments) requestedKeys.add(a.key);
}
const plan: CopyPlanItem[] = [];
let hasChanges = false;
for (const fromKey of [...requestedKeys].sort((a, b) => a.localeCompare(b))) {
if (options.include && !options.include.test(fromKey)) continue;
if (options.exclude && options.exclude.test(fromKey)) continue;
const toKey = renameMap.get(fromKey) ?? fromKey;
const sourceLine = lastAssignmentForKey(sourceAssignments, fromKey);
if (!sourceLine) {
plan.push({ fromKey, toKey, action: 'missing_source' });
continue;
}
const targetLine = lastAssignmentForKey(targetAssignments, toKey);
if (!targetLine) {
const newRaw = toKey === fromKey ? sourceLine.raw : withNewKey(sourceLine, toKey);
targetDoc.lines.push({ kind: 'assignment', ...sourceLine, key: toKey, raw: newRaw });
plan.push({ fromKey, toKey, action: 'copy', fromLine: sourceLine.line });
hasChanges = true;
continue;
}
if (options.onConflict === 'skip') {
plan.push({ fromKey, toKey, action: 'skip', fromLine: sourceLine.line, toLine: targetLine.line });
continue;
}
if (options.onConflict === 'error') {
plan.push({ fromKey, toKey, action: 'conflict', fromLine: sourceLine.line, toLine: targetLine.line });
continue;
}
for (let i = targetDoc.lines.length - 1; i >= 0; i--) {
const l = targetDoc.lines[i];
if (l?.kind === 'assignment' && l.key === toKey) {
const newRaw = toKey === fromKey ? sourceLine.raw : withNewKey(sourceLine, toKey);
targetDoc.lines[i] = { kind: 'assignment', ...sourceLine, key: toKey, raw: newRaw };
plan.push({ fromKey, toKey, action: 'overwrite', fromLine: sourceLine.line, toLine: l.line });
hasChanges = true;
break;
}
}
}
return { output: stringifyDotenvDocument(targetDoc), issues, plan, hasChanges };
}
export type AnnotatePlan = {
key: string;
action: 'inserted' | 'updated' | 'not_found' | 'ambiguous';
keyLines?: number[];
line?: number;
};
export type AnnotateDotenvResult = {
output: string;
issues: DotenvIssue[];
plan: AnnotatePlan;
hasChanges: boolean;
};
export function annotateDotenvKey(options: { contents: string; key: string; comment: string; line?: number }): AnnotateDotenvResult {
const doc = parseDotenvDocument(options.contents);
const issues: DotenvIssue[] = [...doc.issues];
const assignments = listAssignments(doc.lines).filter((a) => a.key === options.key);
const lines = assignments.map((a) => a.line);
if (assignments.length === 0) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'not_found' },
hasChanges: false
};
}
const first = assignments.at(0);
if (!first) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'not_found' },
hasChanges: false
};
}
let target = first;
if (options.line !== undefined) {
const matched = assignments.find((a) => a.line === options.line);
if (!matched) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'ambiguous', keyLines: lines },
hasChanges: false
};
}
target = matched;
} else if (assignments.length > 1) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'ambiguous', keyLines: lines },
hasChanges: false
};
}
const targetIndex = doc.lines.findIndex((l) => l.kind === 'assignment' && l.line === target.line);
if (targetIndex === -1) {
return {
output: options.contents,
issues,
plan: { key: options.key, action: 'not_found' },
hasChanges: false
};
}
const desiredRaw = `${target.leadingWhitespace}# envsitter: ${options.comment}`;
const prev = targetIndex > 0 ? doc.lines[targetIndex - 1] : undefined;
if (prev && prev.kind === 'comment' && prev.raw.trimStart().startsWith('# envsitter:')) {
doc.lines[targetIndex - 1] = { ...prev, raw: desiredRaw };
return { output: stringifyDotenvDocument(doc), issues, plan: { key: options.key, action: 'updated', line: target.line }, hasChanges: true };
}
doc.lines.splice(targetIndex, 0, { kind: 'comment', line: target.line, raw: desiredRaw });
return { output: stringifyDotenvDocument(doc), issues, plan: { key: options.key, action: 'inserted', line: target.line }, hasChanges: true };
}
export type FormatMode = 'sections' | 'global';
export type FormatSort = 'alpha' | 'none';
export type FormatDotenvResult = {
output: string;
issues: DotenvIssue[];
hasChanges: boolean;
};
function splitIntoSections(docLines: ReturnType<typeof parseDotenvDocument>['lines']): Array<{ lines: ReturnType<typeof parseDotenvDocument>['lines'] }> {
const sections: Array<{ lines: ReturnType<typeof parseDotenvDocument>['lines'] }> = [];
let current: ReturnType<typeof parseDotenvDocument>['lines'] = [];
for (const l of docLines) {
if (l.kind === 'blank') {
current.push(l);
sections.push({ lines: current });
current = [];
continue;
}
current.push(l);
}
sections.push({ lines: current });
return sections;
}
function formatSection(sectionLines: ReturnType<typeof parseDotenvDocument>['lines'], sort: FormatSort): ReturnType<typeof parseDotenvDocument>['lines'] {
if (sort === 'none') return sectionLines;
const header: typeof sectionLines = [];
const rest: typeof sectionLines = [];
let sawAssignment = false;
for (const l of sectionLines) {
if (!sawAssignment && l.kind === 'comment') {
header.push(l);
continue;
}
if (l.kind === 'assignment') sawAssignment = true;
rest.push(l);
}
type Block = { key: string; lines: typeof sectionLines };
const blocks: Block[] = [];
const trailing: typeof sectionLines = [];
let pendingComments: typeof sectionLines = [];
for (const l of rest) {
if (l.kind === 'comment') {
pendingComments.push(l);
continue;
}
if (l.kind === 'assignment') {
blocks.push({ key: l.key, lines: [...pendingComments, l] });
pendingComments = [];
continue;
}
trailing.push(...pendingComments);
pendingComments = [];
trailing.push(l);
}
trailing.push(...pendingComments);
blocks.sort((a, b) => a.key.localeCompare(b.key));
return [...header, ...blocks.flatMap((b) => b.lines), ...trailing];
}
export function formatDotenv(options: { contents: string; mode: FormatMode; sort: FormatSort }): FormatDotenvResult {
const doc = parseDotenvDocument(options.contents);
const issues: DotenvIssue[] = [...doc.issues];
if (options.sort === 'none') return { output: options.contents, issues, hasChanges: false };
let nextLines: ReturnType<typeof parseDotenvDocument>['lines'];
if (options.mode === 'global') {
nextLines = formatSection(doc.lines, options.sort);
} else {
const sections = splitIntoSections(doc.lines);
nextLines = sections.flatMap((s) => formatSection(s.lines, options.sort));
}
const nextDoc = { ...doc, lines: nextLines };
const output = stringifyDotenvDocument(nextDoc);
return { output, issues, hasChanges: output !== options.contents };
}
export type ValidateDotenvResult = {
issues: DotenvIssue[];
ok: boolean;
};
export function validateDotenv(contents: string): ValidateDotenvResult {
const doc = parseDotenvDocument(contents);
return { issues: doc.issues, ok: doc.issues.length === 0 };
}

24
src/dotenv/io.ts Normal file
View File

@@ -0,0 +1,24 @@
import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function readTextFileOrEmpty(filePath: string): Promise<string> {
try {
return await readFile(filePath, 'utf8');
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && (error as { code?: string }).code === 'ENOENT') return '';
throw error;
}
}
export async function writeTextFileAtomic(filePath: string, contents: string): Promise<void> {
const dir = dirname(filePath);
const tmp = await mkdtemp(join(dir, '.envsitter-tmp-'));
const tmpFile = join(tmp, 'file');
try {
await writeFile(tmpFile, contents, 'utf8');
await rename(tmpFile, filePath);
} finally {
await rm(tmp, { recursive: true, force: true });
}
}

174
src/file-ops.ts Normal file
View File

@@ -0,0 +1,174 @@
import { readFile } from 'node:fs/promises';
import { annotateDotenvKey, copyDotenvKeys, formatDotenv, validateDotenv } from './dotenv/edit.js';
import { readTextFileOrEmpty, writeTextFileAtomic } from './dotenv/io.js';
export type DotenvIssue = {
line: number;
column: number;
message: string;
};
export type CopyConflictPolicy = 'error' | 'skip' | 'overwrite';
export type CopyPlanItem = {
fromKey: string;
toKey: string;
action: 'copy' | 'skip' | 'overwrite' | 'missing_source' | 'conflict';
fromLine?: number;
toLine?: number;
};
export type FormatMode = 'sections' | 'global';
export type FormatSort = 'alpha' | 'none';
export type AnnotatePlan = {
key: string;
action: 'inserted' | 'updated' | 'not_found' | 'ambiguous';
keyLines?: number[];
line?: number;
};
export type CopyEnvFilesResult = {
from: string;
to: string;
onConflict: CopyConflictPolicy;
willWrite: boolean;
wrote: boolean;
hasChanges: boolean;
issues: DotenvIssue[];
plan: CopyPlanItem[];
};
export async function copyEnvFileKeys(options: {
from: string;
to: string;
keys?: readonly string[];
include?: RegExp;
exclude?: RegExp;
rename?: string;
onConflict?: CopyConflictPolicy;
write?: boolean;
}): Promise<CopyEnvFilesResult> {
const sourceContents = await readFile(options.from, 'utf8');
const targetContents = await readTextFileOrEmpty(options.to);
const result = copyDotenvKeys({
sourceContents,
targetContents,
...(options.keys ? { keys: options.keys } : {}),
...(options.include ? { include: options.include } : {}),
...(options.exclude ? { exclude: options.exclude } : {}),
...(options.rename ? { rename: options.rename } : {}),
onConflict: options.onConflict ?? 'error'
});
const conflicts = result.plan.some((p) => p.action === 'conflict');
const willWrite = options.write === true;
if (willWrite && !conflicts && result.hasChanges) {
await writeTextFileAtomic(options.to, result.output);
}
return {
from: options.from,
to: options.to,
onConflict: options.onConflict ?? 'error',
willWrite,
wrote: willWrite && !conflicts && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues,
plan: result.plan
};
}
export type FormatEnvFileResult = {
file: string;
mode: FormatMode;
sort: FormatSort;
willWrite: boolean;
wrote: boolean;
hasChanges: boolean;
issues: DotenvIssue[];
};
export async function formatEnvFile(options: {
file: string;
mode?: FormatMode;
sort?: FormatSort;
write?: boolean;
}): Promise<FormatEnvFileResult> {
const mode = options.mode ?? 'sections';
const sort = options.sort ?? 'alpha';
const contents = await readFile(options.file, 'utf8');
const result = formatDotenv({ contents, mode, sort });
const willWrite = options.write === true;
if (willWrite && result.hasChanges) {
await writeTextFileAtomic(options.file, result.output);
}
return {
file: options.file,
mode,
sort,
willWrite,
wrote: willWrite && result.hasChanges,
hasChanges: result.hasChanges,
issues: result.issues
};
}
export type AnnotateEnvFileResult = {
file: string;
key: string;
willWrite: boolean;
wrote: boolean;
hasChanges: boolean;
issues: DotenvIssue[];
plan: AnnotatePlan;
};
export async function annotateEnvFile(options: {
file: string;
key: string;
comment: string;
line?: number;
write?: boolean;
}): Promise<AnnotateEnvFileResult> {
const contents = await readFile(options.file, 'utf8');
const result = annotateDotenvKey({
contents,
key: options.key,
comment: options.comment,
...(options.line !== undefined ? { line: options.line } : {})
});
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 ValidateEnvFileResult = {
file: string;
ok: boolean;
issues: DotenvIssue[];
};
export async function validateEnvFile(file: string): Promise<ValidateEnvFileResult> {
const contents = await readFile(file, 'utf8');
const result = validateDotenv(contents);
return { file, ok: result.ok, issues: result.issues };
}

View File

@@ -11,3 +11,14 @@ export {
} from './envsitter.js';
export { type PepperOptions, resolvePepper } from './pepper.js';
export {
annotateEnvFile,
copyEnvFileKeys,
formatEnvFile,
validateEnvFile,
type AnnotateEnvFileResult,
type CopyEnvFilesResult,
type FormatEnvFileResult,
type ValidateEnvFileResult
} from './file-ops.js';

View File

@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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';
async function makeTempFile(fileName: string, contents: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'envsitter-ops-'));
const filePath = join(dir, fileName);
await writeFile(filePath, contents, 'utf8');
return filePath;
}
test('validateEnvFile reports syntax errors with line/column', async () => {
const file = await makeTempFile('.env', ['OK=value', 'BAD-KEY=value', 'NOEQ', "SINGLE='unterminated"].join('\n'));
const result = await validateEnvFile(file);
assert.equal(result.ok, false);
assert.ok(result.issues.some((i) => i.line === 2));
assert.ok(result.issues.some((i) => i.line === 3));
assert.ok(result.issues.some((i) => i.line === 4));
});
test('copyEnvFileKeys copies selected keys and supports rename', async () => {
const from = await makeTempFile('.env.prod', ['A=1', 'B=two', 'C=three'].join('\n') + '\n');
const to = await makeTempFile('.env.staging', ['B=old', 'D=keep'].join('\n') + '\n');
const res = await copyEnvFileKeys({
from,
to,
keys: ['A', 'B'],
rename: 'A=A_RENAMED',
onConflict: 'overwrite',
write: true
});
assert.equal(res.wrote, true);
assert.equal(res.plan.some((p) => p.fromKey === 'A' && p.toKey === 'A_RENAMED' && p.action === 'copy'), true);
assert.equal(res.plan.some((p) => p.fromKey === 'B' && p.toKey === 'B' && p.action === 'overwrite'), true);
const out = await readFile(to, 'utf8');
assert.ok(out.includes('A_RENAMED=1'));
assert.ok(out.includes('B=two'));
assert.ok(out.includes('D=keep'));
});
test('annotateEnvFile inserts and updates envsitter comment', async () => {
const file = await makeTempFile('.env', 'A=1\n');
const first = await annotateEnvFile({ file, key: 'A', comment: 'first', write: true });
assert.equal(first.wrote, true);
const afterFirst = await readFile(file, 'utf8');
assert.ok(afterFirst.startsWith('# envsitter: first\nA=1\n'));
const second = await annotateEnvFile({ file, key: 'A', comment: 'second', write: true });
assert.equal(second.wrote, true);
const afterSecond = await readFile(file, 'utf8');
assert.ok(afterSecond.startsWith('# envsitter: second\nA=1\n'));
});
test('formatEnvFile sorts assignments within sections', async () => {
const file = await makeTempFile(
'.env',
['# section one', 'B=2', 'A=1', '', '# section two', 'Z=9', 'Y=8', ''].join('\n')
);
const res = await formatEnvFile({ file, mode: 'sections', sort: 'alpha', write: true });
assert.equal(res.wrote, true);
const out = await readFile(file, 'utf8');
const expected = ['# section one', 'A=1', 'B=2', '', '# section two', 'Y=8', 'Z=9', ''].join('\n');
assert.equal(out, expected);
});