Add dotenv file operations
This commit is contained in:
195
src/dotenv/document.ts
Normal file
195
src/dotenv/document.ts
Normal 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
314
src/dotenv/edit.ts
Normal 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
24
src/dotenv/io.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user