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

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 });
}
}