Add dotenv file operations
This commit is contained in:
42
CHANGELOG.md
Normal file
42
CHANGELOG.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
This project follows [Semantic Versioning](https://semver.org/) and the format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||||
|
|
||||||
|
## 0.0.3 (2026-01-13)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Dotenv file operations (CLI): `validate`, `copy`, `format`/`reorder`, and `annotate`.
|
||||||
|
- Round-trippable dotenv parsing for file ops (preserves comments/blank lines) with issue reporting that includes line/column.
|
||||||
|
- Library API exports for file ops: `validateEnvFile`, `copyEnvFileKeys`, `formatEnvFile`, `annotateEnvFile`.
|
||||||
|
- Test coverage for file operations.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Package version bumped to `0.0.3`.
|
||||||
|
|
||||||
|
## 0.0.2 (2026-01-12)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Match operators for boolean checks beyond strict equality:
|
||||||
|
- `exists`, `is_empty`, `is_equal`, `partial_match_prefix`, `partial_match_suffix`, `partial_match_regex`, `is_number`, `is_boolean`, `is_string`.
|
||||||
|
- CLI support for `match --op <op>`.
|
||||||
|
- Library support for `EnvSitter.matchKey()` / `EnvSitter.matchKeyBulk()` with matcher operators.
|
||||||
|
- More tests for matcher operators.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Expanded CLI docs and output contract guidance in `README.md`.
|
||||||
|
- Added a reference to `envsitter-guard` in documentation.
|
||||||
|
|
||||||
|
## 0.0.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial public release.
|
||||||
|
- CLI commands: `keys`, `fingerprint`, `match`, `match-by-key`, `scan`.
|
||||||
|
- Library API: `EnvSitter` with safe key listing, deterministic fingerprints (HMAC-SHA-256 + pepper), and outside-in matching.
|
||||||
|
- Support for dotenv sources via local file and external command.
|
||||||
74
README.md
74
README.md
@@ -56,6 +56,16 @@ Commands:
|
|||||||
- `match --file <path> (--key <KEY> | --keys <K1,K2> | --all-keys) [--op <op>] [--candidate <value> | --candidate-stdin]`
|
- `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)`
|
- `match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)`
|
||||||
- `scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]`
|
- `scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]`
|
||||||
|
- `validate --file <path>`
|
||||||
|
- `copy --from <path> --to <path> [--keys <K1,K2>] [--include-regex <re>] [--exclude-regex <re>] [--rename <A=B,C=D>] [--on-conflict error|skip|overwrite] [--write]`
|
||||||
|
- `format --file <path> [--mode sections|global] [--sort alpha|none] [--write]`
|
||||||
|
- `reorder --file <path> [--mode sections|global] [--sort alpha|none] [--write]`
|
||||||
|
- `annotate --file <path> --key <KEY> --comment <text> [--line <n>] [--write]`
|
||||||
|
|
||||||
|
Notes for file operations:
|
||||||
|
|
||||||
|
- Commands that modify files (`copy`, `format`/`reorder`, `annotate`) are dry-run unless `--write` is provided.
|
||||||
|
- These commands never print secret values; output includes keys, booleans, and line numbers only.
|
||||||
|
|
||||||
### List keys
|
### List keys
|
||||||
|
|
||||||
@@ -166,6 +176,47 @@ Optionally restrict which keys to scan:
|
|||||||
envsitter scan --file .env --keys-regex "/(JWT|URL)/" --detect jwt,url
|
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)
|
## Output contract (for LLMs)
|
||||||
|
|
||||||
General rules:
|
General rules:
|
||||||
@@ -186,6 +237,10 @@ JSON outputs:
|
|||||||
- with `--op`: `{ "op": string, "matches": Array<{ "key": string, "match": boolean }> }`
|
- with `--op`: `{ "op": string, "matches": Array<{ "key": string, "match": boolean }> }`
|
||||||
- `match-by-key --json` -> `{ "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"> }> }`
|
- `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
|
## Library API
|
||||||
|
|
||||||
@@ -201,6 +256,25 @@ const fp = await es.fingerprintKey('OPENAI_API_KEY');
|
|||||||
const match = await es.matchCandidate('OPENAI_API_KEY', 'candidate-secret');
|
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
|
### Match operators via the library
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -1,9 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "envsitter",
|
"name": "envsitter",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Safely inspect and match .env secrets without exposing values",
|
"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",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"envsitter": "dist/cli.js"
|
"envsitter": "dist/cli.js"
|
||||||
|
|||||||
145
src/cli.ts
145
src/cli.ts
@@ -1,15 +1,9 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { EnvSitter, type EnvSitterMatcher } from './envsitter.js';
|
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 {
|
function parseRegex(input: string): RegExp {
|
||||||
const trimmed = input.trim();
|
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 --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)',
|
' match-by-key --file <path> (--candidates-json <json> | --candidates-stdin)',
|
||||||
' scan --file <path> [--keys-regex <re>] [--detect jwt,url,base64]',
|
' 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 options:',
|
||||||
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
|
' --pepper-file <path> Defaults to .envsitter/pepper (auto-created)',
|
||||||
@@ -154,6 +153,126 @@ async function run(): Promise<number> {
|
|||||||
return 0;
|
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 file = requireValue(typeof flags['file'] === 'string' ? flags['file'] : undefined, '--file is required');
|
||||||
const pepper = getPepperOptions(flags);
|
const pepper = getPepperOptions(flags);
|
||||||
const envsitter = EnvSitter.fromDotenvFile(file);
|
const envsitter = EnvSitter.fromDotenvFile(file);
|
||||||
@@ -163,7 +282,7 @@ async function run(): Promise<number> {
|
|||||||
const filter = filterRegexRaw ? parseRegex(filterRegexRaw) : undefined;
|
const filter = filterRegexRaw ? parseRegex(filterRegexRaw) : undefined;
|
||||||
|
|
||||||
const keys = await envsitter.listKeys(filter ? { filter } : {});
|
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`);
|
else process.stdout.write(`${keys.join('\n')}\n`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -193,20 +312,20 @@ async function run(): Promise<number> {
|
|||||||
|
|
||||||
if (key) {
|
if (key) {
|
||||||
const match = await envsitter.matchKey(key, matcher, pepperOptions);
|
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;
|
return match ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keysCsv) {
|
if (keysCsv) {
|
||||||
const keys = parseList(keysCsv);
|
const keys = parseList(keysCsv);
|
||||||
const results = await envsitter.matchKeyBulk(keys, matcher, pepperOptions);
|
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;
|
return results.some((r) => r.match) ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allKeys) {
|
if (allKeys) {
|
||||||
const results = await envsitter.matchKeyAll(matcher, pepperOptions);
|
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;
|
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';
|
} from './envsitter.js';
|
||||||
|
|
||||||
export { type PepperOptions, resolvePepper } from './pepper.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