diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d1cd7b2 --- /dev/null +++ b/CHANGELOG.md @@ -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 `. +- 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. diff --git a/README.md b/README.md index f981f04..39bb2a2 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,16 @@ Commands: - `match --file (--key | --keys | --all-keys) [--op ] [--candidate | --candidate-stdin]` - `match-by-key --file (--candidates-json | --candidates-stdin)` - `scan --file [--keys-regex ] [--detect jwt,url,base64]` +- `validate --file ` +- `copy --from --to [--keys ] [--include-regex ] [--exclude-regex ] [--rename ] [--on-conflict error|skip|overwrite] [--write]` +- `format --file [--mode sections|global] [--sort alpha|none] [--write]` +- `reorder --file [--mode sections|global] [--sort alpha|none] [--write]` +- `annotate --file --key --comment [--line ] [--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 diff --git a/package.json b/package.json index ec97fa5..971c820 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/cli.ts b/src/cli.ts index b07b488..9b574c9 100644 --- a/src/cli.ts +++ b/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 (--key | --keys | --all-keys) [--op ] [--candidate | --candidate-stdin]', ' match-by-key --file (--candidates-json | --candidates-stdin)', ' scan --file [--keys-regex ] [--detect jwt,url,base64]', + ' validate --file ', + ' copy --from --to [--keys ] [--include-regex ] [--exclude-regex ] [--rename ] [--on-conflict error|skip|overwrite] [--write]', + ' format --file [--mode sections|global] [--sort alpha|none] [--write]', + ' reorder --file [--mode sections|global] [--sort alpha|none] [--write]', + ' annotate --file --key --comment [--line ] [--write]', '', 'Pepper options:', ' --pepper-file Defaults to .envsitter/pepper (auto-created)', @@ -154,6 +153,126 @@ async function run(): Promise { 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 { 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 { 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; } diff --git a/src/dotenv/document.ts b/src/dotenv/document.ts new file mode 100644 index 0000000..2519760 --- /dev/null +++ b/src/dotenv/document.ts @@ -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; +} diff --git a/src/dotenv/edit.ts b/src/dotenv/edit.ts new file mode 100644 index 0000000..d269db1 --- /dev/null +++ b/src/dotenv/edit.ts @@ -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['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 { + const map = new Map(); + 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(); + 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['lines']): Array<{ lines: ReturnType['lines'] }> { + const sections: Array<{ lines: ReturnType['lines'] }> = []; + let current: ReturnType['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['lines'], sort: FormatSort): ReturnType['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['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 }; +} diff --git a/src/dotenv/io.ts b/src/dotenv/io.ts new file mode 100644 index 0000000..15f26d6 --- /dev/null +++ b/src/dotenv/io.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/src/file-ops.ts b/src/file-ops.ts new file mode 100644 index 0000000..2e9c155 --- /dev/null +++ b/src/file-ops.ts @@ -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 { + 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 { + 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 { + 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 { + const contents = await readFile(file, 'utf8'); + const result = validateDotenv(contents); + return { file, ok: result.ok, issues: result.issues }; +} diff --git a/src/index.ts b/src/index.ts index 526d9a8..a678a43 100644 --- a/src/index.ts +++ b/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'; diff --git a/src/test/dotenv-file-ops.test.ts b/src/test/dotenv-file-ops.test.ts new file mode 100644 index 0000000..882d191 --- /dev/null +++ b/src/test/dotenv-file-ops.test.ts @@ -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 { + 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); +});