@kindgi/dotenv-file 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +105 -2
- package/dist/expand.d.ts +48 -0
- package/dist/expand.d.ts.map +1 -0
- package/dist/expand.js +123 -0
- package/dist/expand.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/layers.d.ts +46 -0
- package/dist/layers.d.ts.map +1 -0
- package/dist/layers.js +54 -0
- package/dist/layers.js.map +1 -0
- package/dist/parse.d.ts +69 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +173 -0
- package/dist/parse.js.map +1 -0
- package/dist/serialize.d.ts +61 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +130 -0
- package/dist/serialize.js.map +1 -0
- package/package.json +47 -3
- package/src/expand.ts +185 -0
- package/src/index.ts +50 -0
- package/src/layers.ts +92 -0
- package/src/parse.ts +266 -0
- package/src/serialize.ts +147 -0
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writer side: render entries so they READ BACK as the exact value
|
|
6
|
+
* written — through `dotenv`'s parser and `expand.ts`'s `${VAR}`
|
|
7
|
+
* expansion (the pipeline Next.js / Vite / dotenv-cli apply) — and keep
|
|
8
|
+
* every untouched line byte-for-byte.
|
|
9
|
+
*
|
|
10
|
+
* Round-trip contract, for any string `v` the writer accepts:
|
|
11
|
+
*
|
|
12
|
+
* expandEnv(envLinesToRecord(parseEnvFile(setKey('', K, v).contents))).values[K] === v
|
|
13
|
+
*
|
|
14
|
+
* Quoting, first rule that fits wins:
|
|
15
|
+
* 1. Plain value (`[A-Za-z0-9_.,:/@+=%~-]+`) → unquoted.
|
|
16
|
+
* 2. Double quotes, when the value has no `"` and no literal `\n` /
|
|
17
|
+
* `\r` sequence (dotenv would turn those into control chars).
|
|
18
|
+
* Real newlines / carriage returns are written as `\n` / `\r`.
|
|
19
|
+
* 3. Single quotes (literal), when the value has no `'` and no `\r`.
|
|
20
|
+
* 4. Backticks (literal), when the value has no `` ` `` and no `\r`.
|
|
21
|
+
* Otherwise the value is not representable in dotenv syntax and
|
|
22
|
+
* `EnvValueNotRepresentableError` is thrown — never a silently
|
|
23
|
+
* different value.
|
|
24
|
+
*
|
|
25
|
+
* `$` is always written as `\$`: expanders substitute `$NAME` even
|
|
26
|
+
* inside quotes, and `\$` is the one escape they all undo.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { ENV_KEY_REGEX, type EnvLine, parseEntryRaw, parseEnvFile } from './parse.js';
|
|
30
|
+
|
|
31
|
+
/** Thrown when a value cannot be written so that it reads back unchanged. */
|
|
32
|
+
export class EnvValueNotRepresentableError extends Error {
|
|
33
|
+
readonly key: string;
|
|
34
|
+
constructor(key: string) {
|
|
35
|
+
super(
|
|
36
|
+
`The value for ${key} cannot be written to a dotenv file so that it reads back unchanged (it mixes quote characters with content no dotenv quoting style can hold).`,
|
|
37
|
+
);
|
|
38
|
+
this.name = 'EnvValueNotRepresentableError';
|
|
39
|
+
this.key = key;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PLAIN = /^[A-Za-z0-9_.,:/@+=%~-]+$/;
|
|
44
|
+
|
|
45
|
+
/** Render one `KEY=value` entry (throws `EnvValueNotRepresentableError`). */
|
|
46
|
+
export function renderEntry(
|
|
47
|
+
key: string,
|
|
48
|
+
value: string,
|
|
49
|
+
options: { exported?: boolean } = {},
|
|
50
|
+
): string {
|
|
51
|
+
const prefix = options.exported === true ? 'export ' : '';
|
|
52
|
+
return `${prefix}${key}=${renderValue(key, value)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderValue(key: string, value: string): string {
|
|
56
|
+
if (value === '') return '""';
|
|
57
|
+
const escaped = value.replace(/\$/g, '\\$');
|
|
58
|
+
if (PLAIN.test(escaped)) return escaped;
|
|
59
|
+
if (!escaped.includes('"') && !/\\[nr]/.test(escaped)) {
|
|
60
|
+
return `"${escaped.replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
|
|
61
|
+
}
|
|
62
|
+
if (!value.includes('\r')) {
|
|
63
|
+
if (!escaped.includes("'")) return `'${escaped}'`;
|
|
64
|
+
if (!escaped.includes('`')) return `\`${escaped}\``;
|
|
65
|
+
}
|
|
66
|
+
throw new EnvValueNotRepresentableError(key);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Serialize lines back to file text with a trailing newline. An entry
|
|
71
|
+
* whose `raw` still parses to its `key` / `value` is emitted verbatim
|
|
72
|
+
* (hand-written quoting, `export`, inline comments survive); any other
|
|
73
|
+
* entry is re-rendered from `key` / `value`. Non-entry lines are
|
|
74
|
+
* emitted verbatim — a malformed line is never dropped.
|
|
75
|
+
*/
|
|
76
|
+
export function serializeEnvFile(lines: readonly EnvLine[]): string {
|
|
77
|
+
const body = lines.map(serializeLine).join('\n');
|
|
78
|
+
return body === '' ? '' : `${body}\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function serializeLine(line: EnvLine): string {
|
|
82
|
+
if (line.kind !== 'entry' || line.key === undefined || line.value === undefined) return line.raw;
|
|
83
|
+
const reparsed = parseEntryRaw(line.raw);
|
|
84
|
+
if (
|
|
85
|
+
reparsed !== undefined &&
|
|
86
|
+
reparsed.key === line.key &&
|
|
87
|
+
reparsed.value === line.value &&
|
|
88
|
+
(reparsed.exported === true) === (line.exported === true)
|
|
89
|
+
) {
|
|
90
|
+
return line.raw;
|
|
91
|
+
}
|
|
92
|
+
return renderEntry(line.key, line.value, { exported: line.exported === true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set `KEY=value`. When the key already exists, the LAST occurrence —
|
|
97
|
+
* the one readers see — is replaced in place (keeping its `export`
|
|
98
|
+
* prefix); otherwise the entry is appended. `key` must be a POSIX env
|
|
99
|
+
* name (`ENV_KEY_REGEX`).
|
|
100
|
+
*/
|
|
101
|
+
export function setKey(
|
|
102
|
+
rawFileContents: string,
|
|
103
|
+
key: string,
|
|
104
|
+
value: string,
|
|
105
|
+
): { readonly contents: string; readonly overwritten: boolean } {
|
|
106
|
+
if (!ENV_KEY_REGEX.test(key)) {
|
|
107
|
+
throw new TypeError(`Invalid env key "${key}" — must match ${ENV_KEY_REGEX.source}.`);
|
|
108
|
+
}
|
|
109
|
+
const lines = [...parseEnvFile(rawFileContents)];
|
|
110
|
+
let target = -1;
|
|
111
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
112
|
+
const line = lines[i];
|
|
113
|
+
if (line?.kind === 'entry' && line.key === key) {
|
|
114
|
+
target = i;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const exported = target === -1 ? false : lines[target]?.exported === true;
|
|
119
|
+
const raw = renderEntry(key, value, { exported });
|
|
120
|
+
const replacement: EnvLine = exported
|
|
121
|
+
? { kind: 'entry', raw, key, value, exported: true }
|
|
122
|
+
: { kind: 'entry', raw, key, value };
|
|
123
|
+
if (target === -1) lines.push(replacement);
|
|
124
|
+
else lines[target] = replacement;
|
|
125
|
+
return { contents: serializeEnvFile(lines), overwritten: target !== -1 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove every entry for KEY; unrelated lines are preserved.
|
|
130
|
+
* Idempotent — `removed: false` when the key wasn't present.
|
|
131
|
+
*/
|
|
132
|
+
export function unsetKey(
|
|
133
|
+
rawFileContents: string,
|
|
134
|
+
key: string,
|
|
135
|
+
): { readonly contents: string; readonly removed: boolean } {
|
|
136
|
+
const lines = parseEnvFile(rawFileContents);
|
|
137
|
+
let removed = false;
|
|
138
|
+
const kept: EnvLine[] = [];
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
if (line.kind === 'entry' && line.key === key) {
|
|
141
|
+
removed = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
kept.push(line);
|
|
145
|
+
}
|
|
146
|
+
return { contents: serializeEnvFile(kept), removed };
|
|
147
|
+
}
|