Add dotenv file operations
This commit is contained in:
145
src/cli.ts
145
src/cli.ts
@@ -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
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 });
|
||||
}
|
||||
}
|
||||
174
src/file-ops.ts
Normal file
174
src/file-ops.ts
Normal 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 };
|
||||
}
|
||||
11
src/index.ts
11
src/index.ts
@@ -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';
|
||||
|
||||
77
src/test/dotenv-file-ops.test.ts
Normal file
77
src/test/dotenv-file-ops.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user