@envseal/core 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/dist/approvals.d.ts +13 -0
- package/dist/approvals.js +73 -0
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +37 -0
- package/dist/broker.d.ts +31 -0
- package/dist/broker.js +449 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +148 -0
- package/dist/guard.d.ts +66 -0
- package/dist/guard.js +157 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/manifest.d.ts +24 -0
- package/dist/manifest.js +165 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +87 -0
- package/dist/presence.d.ts +20 -0
- package/dist/presence.js +58 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +338 -0
- package/dist/sinks/cli-sink-base.d.ts +88 -0
- package/dist/sinks/cli-sink-base.js +217 -0
- package/dist/sinks/doppler.d.ts +45 -0
- package/dist/sinks/doppler.js +198 -0
- package/dist/sinks/dotenv.d.ts +57 -0
- package/dist/sinks/dotenv.js +407 -0
- package/dist/sinks/keychain.d.ts +21 -0
- package/dist/sinks/keychain.js +333 -0
- package/dist/sinks/onepassword.d.ts +58 -0
- package/dist/sinks/onepassword.js +183 -0
- package/dist/sinks/registry.d.ts +4 -0
- package/dist/sinks/registry.js +63 -0
- package/dist/sinks/sops.d.ts +54 -0
- package/dist/sinks/sops.js +254 -0
- package/dist/sinks/types.d.ts +10 -0
- package/dist/sinks/types.js +2 -0
- package/dist/sinks/vault.d.ts +41 -0
- package/dist/sinks/vault.js +156 -0
- package/dist/tickets.d.ts +49 -0
- package/dist/tickets.js +179 -0
- package/dist/validation-state.d.ts +33 -0
- package/dist/validation-state.js +48 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +133 -0
- package/package.json +38 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { asSecret, SepError } from '@envseal/protocol';
|
|
5
|
+
import { CliSinkBase, execCli, exitCodeOf } from './cli-sink-base.js';
|
|
6
|
+
import { unsafeSecretToUtf8 } from './dotenv.js';
|
|
7
|
+
/**
|
|
8
|
+
* Doppler secrets live under a project/config pair, the provider's analog of
|
|
9
|
+
* vault's mount/path: the project defaults to the sanitized project directory
|
|
10
|
+
* name (same leaf keychain.ts and vault.ts scope by) and the config to `dev`,
|
|
11
|
+
* both overridable per environment via ENVSEAL_DOPPLER_PROJECT /
|
|
12
|
+
* ENVSEAL_DOPPLER_CONFIG. Overrides pass through verbatim — an explicit name
|
|
13
|
+
* is aimed at an existing project, not renamed behind the user's back.
|
|
14
|
+
*
|
|
15
|
+
* Transport: `doppler secrets set KEY` reads the value from stdin when stdin
|
|
16
|
+
* is a pipe (`cat cert.pem | doppler secrets set TLS_CERT` is the documented
|
|
17
|
+
* form), so values never touch argv. The `KEY=value` argv form also exists
|
|
18
|
+
* but is rejected here — argv is world-readable via process listings. The
|
|
19
|
+
* other stdin route, `secrets upload` with a JSON blob, replaces the config's
|
|
20
|
+
* whole secret set: scoping it to one key would mean a read-merge-upload
|
|
21
|
+
* cycle that both pulls unrelated secrets through this process and races
|
|
22
|
+
* concurrent writers into lost updates. One-key set through stdin has no
|
|
23
|
+
* such race.
|
|
24
|
+
*
|
|
25
|
+
* Auth: DOPPLER_TOKEN in the environment or a token captured by
|
|
26
|
+
* `doppler configure`. This sink never passes tokens itself.
|
|
27
|
+
*/
|
|
28
|
+
/** The config envseal writes to when ENVSEAL_DOPPLER_CONFIG does not override it. */
|
|
29
|
+
const DEFAULT_CONFIG = 'dev';
|
|
30
|
+
/**
|
|
31
|
+
* Doppler documents no exit code for a missing secret; absence arrives as
|
|
32
|
+
* stderr wording instead. Since DopplerHQ/cli PR #215 the CLI prints
|
|
33
|
+
* `Could not find requested secret: NAME` and exits nonzero (older builds
|
|
34
|
+
* returned an empty --plain body with exit 0, which read() maps to null) —
|
|
35
|
+
* both shapes mean ABSENCE, which read()/remove() must report as null/false
|
|
36
|
+
* rather than a thrown failure.
|
|
37
|
+
*
|
|
38
|
+
* The marker, not bare "not found", is what carries that meaning: scope
|
|
39
|
+
* misses surface as their own errors (`project "x" not found`, `config
|
|
40
|
+
* "dev" not found`), and mapping those to null would turn a mistyped
|
|
41
|
+
* ENVSEAL_DOPPLER_PROJECT into an ensure() prompt loop on every run instead
|
|
42
|
+
* of a loud error naming the bad scope. Any other nonzero exit (expired
|
|
43
|
+
* token, unreachable API) stays loud for the same reason.
|
|
44
|
+
*/
|
|
45
|
+
const MISSING_SECRET = /could not find requested secrets?\b/i;
|
|
46
|
+
/**
|
|
47
|
+
* True when a doppler credential exists: DOPPLER_TOKEN set, or a config file
|
|
48
|
+
* on disk from a previous `doppler configure` (~/.doppler/.doppler.json, the
|
|
49
|
+
* documented location). homedir() is resolved per call — os.homedir() honors
|
|
50
|
+
* HOME/USERPROFILE, which lets tests isolate the file branch. The check also
|
|
51
|
+
* degrades safely: a wrong guess about the path yields false (with an
|
|
52
|
+
* unavailable message that names DOPPLER_TOKEN), never a false "ready".
|
|
53
|
+
*/
|
|
54
|
+
export function dopplerCredentialConfigured() {
|
|
55
|
+
if (process.env.DOPPLER_TOKEN)
|
|
56
|
+
return true;
|
|
57
|
+
return existsSync(join(homedir(), '.doppler', '.doppler.json'));
|
|
58
|
+
}
|
|
59
|
+
/** The project directory name every other provider sink scopes entries by. */
|
|
60
|
+
function projectIdOf(paths) {
|
|
61
|
+
return paths.root.split(/[\\/]/).pop() ?? 'unknown';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Map a project directory name onto doppler's project-name alphabet
|
|
65
|
+
* (lowercase letters, digits, dashes, underscores). Best effort by design:
|
|
66
|
+
* envseal never creates the project — the user provisions it under this name
|
|
67
|
+
* or points ENVSEAL_DOPPLER_PROJECT at an existing one, and the first
|
|
68
|
+
* operation fails loudly with doppler's own error if it does not exist.
|
|
69
|
+
*/
|
|
70
|
+
function sanitizeProjectName(raw) {
|
|
71
|
+
const cleaned = raw
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
74
|
+
.replace(/^-+|-+$/g, '')
|
|
75
|
+
.slice(0, 60)
|
|
76
|
+
.replace(/-+$/g, '');
|
|
77
|
+
return cleaned.length > 0 ? cleaned : 'envseal-project';
|
|
78
|
+
}
|
|
79
|
+
/** The project/config pair every operation targets, resolved per call. */
|
|
80
|
+
function scopeFor(paths) {
|
|
81
|
+
return {
|
|
82
|
+
project: process.env.ENVSEAL_DOPPLER_PROJECT ?? sanitizeProjectName(projectIdOf(paths)),
|
|
83
|
+
config: process.env.ENVSEAL_DOPPLER_CONFIG ?? DEFAULT_CONFIG,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Absence signal only: a real exit code plus doppler's missing-secret wording. */
|
|
87
|
+
function isNotFound(error) {
|
|
88
|
+
const stderr = error?.stderr;
|
|
89
|
+
return exitCodeOf(error) !== undefined && stderr !== undefined && MISSING_SECRET.test(stderr);
|
|
90
|
+
}
|
|
91
|
+
export class DopplerSink extends CliSinkBase {
|
|
92
|
+
id = 'doppler';
|
|
93
|
+
requiredCommands = ['doppler'];
|
|
94
|
+
unavailableReason() {
|
|
95
|
+
return 'the doppler CLI is not installed or no Doppler credential is configured (DOPPLER_TOKEN or doppler configure)';
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Single choke point for every spawn this sink makes. Production resolves
|
|
99
|
+
* `doppler` off PATH; tests substitute a fake provider here instead of
|
|
100
|
+
* staging one on PATH, because Windows cannot spawn shebang scripts and
|
|
101
|
+
* modern Node refuses .cmd shims outright (CVE-2024-27980) — a PATH-only
|
|
102
|
+
* double would leave the parsing/error-mapping logic unexercised there
|
|
103
|
+
* (same trade as vault.ts).
|
|
104
|
+
*/
|
|
105
|
+
run(args, options = {}) {
|
|
106
|
+
return execCli('doppler', [...args], options);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Doppler on PATH AND a credential to talk to. The base probe deliberately
|
|
110
|
+
* checks binaries only — credential configuration is validated here and at
|
|
111
|
+
* operation time, where its absence can produce a precise message.
|
|
112
|
+
*/
|
|
113
|
+
async available(_paths) {
|
|
114
|
+
return (await super.available(_paths)) && dopplerCredentialConfigured();
|
|
115
|
+
}
|
|
116
|
+
async read(paths, key) {
|
|
117
|
+
await this.requireReady();
|
|
118
|
+
const scope = scopeFor(paths);
|
|
119
|
+
try {
|
|
120
|
+
const { stdout } = await this.run([
|
|
121
|
+
'secrets',
|
|
122
|
+
'get',
|
|
123
|
+
key,
|
|
124
|
+
'--plain',
|
|
125
|
+
'--project',
|
|
126
|
+
scope.project,
|
|
127
|
+
'--config',
|
|
128
|
+
scope.config,
|
|
129
|
+
]);
|
|
130
|
+
// --plain prints the bare value plus one trailing newline; an empty body
|
|
131
|
+
// is the exit-0 shape of a miss, the same read keychain.ts gives
|
|
132
|
+
// secret-tool. Strip exactly that one newline, byte-exact, never a
|
|
133
|
+
// blanket trim.
|
|
134
|
+
if (stdout.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
return asSecret(Buffer.from(stdout.replace(/\r?\n$/, ''), 'utf8'));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (isNotFound(error))
|
|
140
|
+
return null;
|
|
141
|
+
throw this.sinkFailure('read', error, key);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async write(paths, key, value) {
|
|
145
|
+
await this.requireReady();
|
|
146
|
+
const scope = scopeFor(paths);
|
|
147
|
+
try {
|
|
148
|
+
// Value on stdin only. One caveat left to the live round-trip suite
|
|
149
|
+
// where a CLI exists: doppler may trim a single trailing newline off
|
|
150
|
+
// piped input, echo-style — envseal values do not end in newlines in
|
|
151
|
+
// practice, so nothing here pre-trims or pre-pads to compensate.
|
|
152
|
+
await this.run(['secrets', 'set', key, '--project', scope.project, '--config', scope.config], { input: unsafeSecretToUtf8(value) });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
throw this.sinkFailure('write', error, key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async remove(paths, key) {
|
|
159
|
+
await this.requireReady();
|
|
160
|
+
const scope = scopeFor(paths);
|
|
161
|
+
try {
|
|
162
|
+
// --yes: the interactive confirmation can never be answered on a pipe.
|
|
163
|
+
await this.run([
|
|
164
|
+
'secrets',
|
|
165
|
+
'delete',
|
|
166
|
+
key,
|
|
167
|
+
'--yes',
|
|
168
|
+
'--project',
|
|
169
|
+
scope.project,
|
|
170
|
+
'--config',
|
|
171
|
+
scope.config,
|
|
172
|
+
]);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (isNotFound(error))
|
|
177
|
+
return false;
|
|
178
|
+
throw this.sinkFailure('remove', error, key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Every operation re-probes the CLI — available() may have been consulted
|
|
183
|
+
* long before, and the binary can vanish in between — then demands a
|
|
184
|
+
* credential separately, whose absence gets its own precise message instead
|
|
185
|
+
* of hiding behind the generic reason.
|
|
186
|
+
*/
|
|
187
|
+
async requireReady() {
|
|
188
|
+
await this.requirePrerequisites();
|
|
189
|
+
if (!dopplerCredentialConfigured()) {
|
|
190
|
+
throw new SepError({
|
|
191
|
+
code: 'SEP_SINK_UNAVAILABLE',
|
|
192
|
+
userMessage: `The ${this.id} sink is not available — no Doppler credential is configured (DOPPLER_TOKEN or doppler configure).`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export const dopplerSink = new DopplerSink();
|
|
198
|
+
//# sourceMappingURL=doppler.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { SecretValue } from '@envseal/protocol';
|
|
2
|
+
import type { ProjectPaths } from '../paths.js';
|
|
3
|
+
import type { Sink } from './types.js';
|
|
4
|
+
export type DotenvLine = {
|
|
5
|
+
kind: 'comment';
|
|
6
|
+
text: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'blank';
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'raw';
|
|
12
|
+
text: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'assignment';
|
|
15
|
+
text: string;
|
|
16
|
+
key: string;
|
|
17
|
+
value: string;
|
|
18
|
+
quote: '"' | "'" | null;
|
|
19
|
+
exported: boolean;
|
|
20
|
+
lead: string;
|
|
21
|
+
prefix: string;
|
|
22
|
+
trailing: string;
|
|
23
|
+
};
|
|
24
|
+
export interface ParsedDotenv {
|
|
25
|
+
lines: DotenvLine[];
|
|
26
|
+
eol: '\r\n' | '\n';
|
|
27
|
+
bom: boolean;
|
|
28
|
+
trailingNewline: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The single permitted SecretValue -> string conversion in the whole broker.
|
|
32
|
+
* It exists so a value can be handed to the dotenv sink for writing (and, in
|
|
33
|
+
* turn, fed to the redactor). It MUST NOT be re-exported from the package
|
|
34
|
+
* index and MUST NOT be used for logging, printing, or error messages.
|
|
35
|
+
*/
|
|
36
|
+
export declare function unsafeSecretToUtf8(value: SecretValue): string;
|
|
37
|
+
export declare function parseDotenv(text: string): ParsedDotenv;
|
|
38
|
+
export declare function serializeDotenv(parsed: ParsedDotenv): string;
|
|
39
|
+
export interface WriteDotenvOptions {
|
|
40
|
+
allowUnsafe?: boolean;
|
|
41
|
+
description?: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function readDotenv(paths: ProjectPaths): Record<string, string>;
|
|
44
|
+
export declare function setDotenvValue(paths: ProjectPaths, key: string, value: string, options?: WriteDotenvOptions): void;
|
|
45
|
+
export declare function removeDotenvKey(paths: ProjectPaths, key: string, options?: {
|
|
46
|
+
allowUnsafe?: boolean;
|
|
47
|
+
}): boolean;
|
|
48
|
+
export declare class DotenvSink implements Sink {
|
|
49
|
+
readonly id = "dotenv";
|
|
50
|
+
available(_paths: ProjectPaths): Promise<boolean>;
|
|
51
|
+
read(paths: ProjectPaths, key: string): Promise<SecretValue | null>;
|
|
52
|
+
write(paths: ProjectPaths, key: string, value: SecretValue, options?: WriteDotenvOptions): Promise<void>;
|
|
53
|
+
remove(paths: ProjectPaths, key: string, options?: {
|
|
54
|
+
allowUnsafe?: boolean;
|
|
55
|
+
}): Promise<boolean>;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=dotenv.d.ts.map
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, chmodSync, } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { dirname, basename, join, relative } from 'node:path';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { asSecret, SepError } from '@envseal/protocol';
|
|
6
|
+
import { ensureStateDir } from '../paths.js';
|
|
7
|
+
const isPosix = process.platform !== 'win32';
|
|
8
|
+
/**
|
|
9
|
+
* The single permitted SecretValue -> string conversion in the whole broker.
|
|
10
|
+
* It exists so a value can be handed to the dotenv sink for writing (and, in
|
|
11
|
+
* turn, fed to the redactor). It MUST NOT be re-exported from the package
|
|
12
|
+
* index and MUST NOT be used for logging, printing, or error messages.
|
|
13
|
+
*/
|
|
14
|
+
export function unsafeSecretToUtf8(value) {
|
|
15
|
+
return value.toString('utf8');
|
|
16
|
+
}
|
|
17
|
+
const ASSIGNMENT_RE = /^(\s*)(?:export\s+)?([A-Za-z0-9_.]+)\s*=\s*(.*)$/;
|
|
18
|
+
export function parseDotenv(text) {
|
|
19
|
+
const bom = text.charCodeAt(0) === 0xfeff;
|
|
20
|
+
const body = bom ? text.slice(1) : text;
|
|
21
|
+
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
|
22
|
+
const rawLines = body.split(/\r\n|\n/);
|
|
23
|
+
let trailingNewline = false;
|
|
24
|
+
if (body.endsWith('\n')) {
|
|
25
|
+
rawLines.pop();
|
|
26
|
+
trailingNewline = true;
|
|
27
|
+
}
|
|
28
|
+
const lines = rawLines.map(parseLine);
|
|
29
|
+
return { lines, eol, bom, trailingNewline };
|
|
30
|
+
}
|
|
31
|
+
function parseLine(raw) {
|
|
32
|
+
if (/^\s*$/.test(raw))
|
|
33
|
+
return { kind: 'blank', text: raw };
|
|
34
|
+
if (/^\s*#/.test(raw))
|
|
35
|
+
return { kind: 'comment', text: raw };
|
|
36
|
+
const match = ASSIGNMENT_RE.exec(raw);
|
|
37
|
+
if (!match)
|
|
38
|
+
return { kind: 'raw', text: raw };
|
|
39
|
+
const lead = match[1] ?? '';
|
|
40
|
+
const key = match[2] ?? '';
|
|
41
|
+
const exported = /^export\s/.test(raw.slice(lead.length));
|
|
42
|
+
let rest = match[3] ?? '';
|
|
43
|
+
let quote = null;
|
|
44
|
+
let value = '';
|
|
45
|
+
let trailing = '';
|
|
46
|
+
const first = rest[0];
|
|
47
|
+
if (first === '"' || first === "'") {
|
|
48
|
+
quote = first;
|
|
49
|
+
rest = rest.slice(1);
|
|
50
|
+
let str = '';
|
|
51
|
+
let i = 0;
|
|
52
|
+
let closed = false;
|
|
53
|
+
for (; i < rest.length; i++) {
|
|
54
|
+
const ch = rest[i];
|
|
55
|
+
if (ch === '\\' && quote === '"') {
|
|
56
|
+
const next = rest[i + 1];
|
|
57
|
+
i++;
|
|
58
|
+
if (next === 'n')
|
|
59
|
+
str += '\n';
|
|
60
|
+
else if (next === 'r')
|
|
61
|
+
str += '\r';
|
|
62
|
+
else if (next === 't')
|
|
63
|
+
str += '\t';
|
|
64
|
+
else if (next === '"')
|
|
65
|
+
str += '"';
|
|
66
|
+
else if (next === '\\')
|
|
67
|
+
str += '\\';
|
|
68
|
+
else
|
|
69
|
+
str += next ?? '';
|
|
70
|
+
}
|
|
71
|
+
else if (ch === quote) {
|
|
72
|
+
closed = true;
|
|
73
|
+
i++;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
str += ch;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!closed)
|
|
81
|
+
return { kind: 'raw', text: raw };
|
|
82
|
+
value = str;
|
|
83
|
+
trailing = rest.slice(i);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const commentMatch = /(\s+)#/.exec(rest);
|
|
87
|
+
if (commentMatch && commentMatch[1]) {
|
|
88
|
+
value = rest.slice(0, commentMatch.index).replace(/\s+$/, '');
|
|
89
|
+
trailing = rest.slice(commentMatch.index);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
value = rest.replace(/\s+$/, '');
|
|
93
|
+
trailing = '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
kind: 'assignment',
|
|
98
|
+
text: raw,
|
|
99
|
+
key,
|
|
100
|
+
value,
|
|
101
|
+
quote,
|
|
102
|
+
exported,
|
|
103
|
+
lead,
|
|
104
|
+
prefix: exported ? 'export ' : '',
|
|
105
|
+
trailing,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function serializeDotenv(parsed) {
|
|
109
|
+
const body = parsed.lines.map((line) => line.text).join(parsed.eol);
|
|
110
|
+
let out = parsed.bom ? '\uFEFF' : '';
|
|
111
|
+
out += body;
|
|
112
|
+
if (parsed.trailingNewline)
|
|
113
|
+
out += parsed.eol;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
function escapeDoubleQuoted(value) {
|
|
117
|
+
return value
|
|
118
|
+
.replace(/\\/g, '\\\\')
|
|
119
|
+
.replace(/"/g, '\\"')
|
|
120
|
+
.replace(/\n/g, '\\n')
|
|
121
|
+
.replace(/\r/g, '\\r');
|
|
122
|
+
}
|
|
123
|
+
function renderValue(value, prefer = null) {
|
|
124
|
+
const needsQuote = value.length === 0 ||
|
|
125
|
+
/[\s#\\]/.test(value) ||
|
|
126
|
+
value.includes('\n') ||
|
|
127
|
+
value.includes('\r') ||
|
|
128
|
+
value.includes('"') ||
|
|
129
|
+
value.includes("'");
|
|
130
|
+
if (!needsQuote) {
|
|
131
|
+
if (prefer === '"')
|
|
132
|
+
return `"${value}"`;
|
|
133
|
+
if (prefer === "'")
|
|
134
|
+
return `'${value}'`;
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
if (prefer === "'" && !/[\r\n'"\\]/.test(value)) {
|
|
138
|
+
return `'${value}'`;
|
|
139
|
+
}
|
|
140
|
+
return `"${escapeDoubleQuoted(value)}"`;
|
|
141
|
+
}
|
|
142
|
+
function rebuildAssignment(line, value) {
|
|
143
|
+
if (line.kind !== 'assignment')
|
|
144
|
+
return line;
|
|
145
|
+
const text = `${line.lead}${line.prefix}${line.key}=${renderValue(value, line.quote)}${line.trailing}`;
|
|
146
|
+
return { ...line, value, text };
|
|
147
|
+
}
|
|
148
|
+
// On Windows, touching a file another process has a handle on intermittently
|
|
149
|
+
// fails with EPERM / EACCES / EBUSY: antivirus scanners, the search indexer and
|
|
150
|
+
// editors all take brief handles on a file they just saw written. The operation
|
|
151
|
+
// succeeds once the handle is released, so the fix is a bounded retry rather
|
|
152
|
+
// than a fallback to something non-atomic. Found by the dotenv property test,
|
|
153
|
+
// which only reproduced it after ~250 writes in quick succession.
|
|
154
|
+
//
|
|
155
|
+
// F-W7-4: this used to guard the rename only. The same transient handle fails
|
|
156
|
+
// the *read* just as readily — and a read failure happens before the rename
|
|
157
|
+
// loop is ever reached, so the retry budget never ran.
|
|
158
|
+
const RETRY_DELAYS_MS = [1, 2, 5, 10, 25, 50, 100];
|
|
159
|
+
const TRANSIENT_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
160
|
+
function withTransientRetry(operation) {
|
|
161
|
+
let lastError;
|
|
162
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
163
|
+
try {
|
|
164
|
+
return operation();
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const code = error.code;
|
|
168
|
+
if (code === undefined || !TRANSIENT_CODES.has(code))
|
|
169
|
+
throw error;
|
|
170
|
+
lastError = error;
|
|
171
|
+
const delay = RETRY_DELAYS_MS[attempt];
|
|
172
|
+
if (delay === undefined)
|
|
173
|
+
break;
|
|
174
|
+
// Synchronous sleep: this path must stay sync because the whole sink API
|
|
175
|
+
// is sync, and the waits are sub-100ms.
|
|
176
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
throw lastError;
|
|
180
|
+
}
|
|
181
|
+
function readFileIfPresent(path) {
|
|
182
|
+
try {
|
|
183
|
+
return withTransientRetry(() => readFileSync(path, 'utf8'));
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (error.code !== 'ENOENT')
|
|
187
|
+
throw error;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function writeTempFile(tmp, content) {
|
|
192
|
+
const fd = openSync(tmp, 'wx', 0o600);
|
|
193
|
+
try {
|
|
194
|
+
writeSync(fd, content, null, 'utf8');
|
|
195
|
+
fsyncSync(fd);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
closeSync(fd);
|
|
199
|
+
}
|
|
200
|
+
if (isPosix)
|
|
201
|
+
chmodSync(tmp, 0o600);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Write `content` to `target` so that a reader never observes a partial file.
|
|
205
|
+
*
|
|
206
|
+
* The temp file holds the complete plaintext, so where it lives matters
|
|
207
|
+
* (F-W7-3): `.<basename>.<hex>.tmp` next to the target is `..env.<hex>.tmp`,
|
|
208
|
+
* which a `.gitignore` entry of `.env` does NOT match — a leftover would be a
|
|
209
|
+
* stageable plaintext secret. It goes in `.envseal/` instead, which is mode
|
|
210
|
+
* 0700 and carries its own `.gitignore` of `*` (see ensureStateDir).
|
|
211
|
+
*
|
|
212
|
+
* `.envseal/` is `<root>/.envseal`, so it is on the same volume as `.env` by
|
|
213
|
+
* construction and the rename stays atomic. If someone has made it a junction
|
|
214
|
+
* onto another volume the rename reports EXDEV, and we fall back to a sibling
|
|
215
|
+
* temp file rather than performing a non-atomic cross-volume copy.
|
|
216
|
+
*/
|
|
217
|
+
function atomicWrite(paths, target, content) {
|
|
218
|
+
const suffix = `${basename(target)}.${randomBytes(6).toString('hex')}.tmp`;
|
|
219
|
+
const sibling = join(dirname(target), `.${suffix}`);
|
|
220
|
+
let tmp = join(paths.stateDir, suffix);
|
|
221
|
+
try {
|
|
222
|
+
ensureStateDir(paths);
|
|
223
|
+
writeTempFile(tmp, content);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
tmp = sibling;
|
|
227
|
+
writeTempFile(tmp, content);
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
renameOverwrite(tmp, target);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error.code !== 'EXDEV')
|
|
234
|
+
throw error;
|
|
235
|
+
writeTempFile(sibling, content);
|
|
236
|
+
renameOverwrite(sibling, target);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function renameOverwrite(tmp, target) {
|
|
240
|
+
try {
|
|
241
|
+
withTransientRetry(() => renameSync(tmp, target));
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
try {
|
|
245
|
+
unlinkSync(tmp);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// best effort: leaving a stray tmp file is preferable to masking the error
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* F-W7-4: every filesystem failure used to escape as a bare Node error, so the
|
|
255
|
+
* CLI never mapped it to exit code 5 and the message carried the target's
|
|
256
|
+
* absolute path. Callers see SEP_SINK_WRITE_FAILED with the errno only.
|
|
257
|
+
*/
|
|
258
|
+
function asSinkWriteError(error, target) {
|
|
259
|
+
if (error instanceof SepError)
|
|
260
|
+
return error;
|
|
261
|
+
const code = error.code;
|
|
262
|
+
const name = basename(target);
|
|
263
|
+
const detail = code === undefined
|
|
264
|
+
? ''
|
|
265
|
+
: ` (${code}) — it may be open in another program, read-only, or on a full disk`;
|
|
266
|
+
return new SepError({
|
|
267
|
+
code: 'SEP_SINK_WRITE_FAILED',
|
|
268
|
+
userMessage: `Could not update ${name} in the project directory${detail}.`,
|
|
269
|
+
details: { file: name, errno: code ?? null },
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function runGit(cwd, args) {
|
|
273
|
+
try {
|
|
274
|
+
execFileSync('git', args, { cwd, stdio: 'ignore' });
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
const status = error.status;
|
|
279
|
+
return typeof status === 'number' ? status : 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function assertGitSafe(paths, allowUnsafe) {
|
|
283
|
+
if (allowUnsafe)
|
|
284
|
+
return;
|
|
285
|
+
if (runGit(paths.root, ['rev-parse', '--is-inside-work-tree']) !== 0)
|
|
286
|
+
return;
|
|
287
|
+
const relPath = relative(paths.root, paths.dotenv);
|
|
288
|
+
const tracked = runGit(paths.root, ['ls-files', '--error-unmatch', '--', relPath]) === 0;
|
|
289
|
+
if (tracked) {
|
|
290
|
+
throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
|
|
291
|
+
}
|
|
292
|
+
const ignored = runGit(paths.root, ['check-ignore', '-q', relPath]) === 0;
|
|
293
|
+
if (!ignored) {
|
|
294
|
+
throw new SepError({ code: 'SEP_GITIGNORE_UNSAFE' });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export function readDotenv(paths) {
|
|
298
|
+
const text = readFileIfPresent(paths.dotenv);
|
|
299
|
+
if (text === null)
|
|
300
|
+
return {};
|
|
301
|
+
const parsed = parseDotenv(text);
|
|
302
|
+
const out = {};
|
|
303
|
+
for (const line of parsed.lines) {
|
|
304
|
+
if (line.kind === 'assignment' && line.key.length > 0) {
|
|
305
|
+
out[line.key] = line.value;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
function sanitizeComment(description) {
|
|
311
|
+
return description.replace(/[\r\n]+/g, ' ').trim();
|
|
312
|
+
}
|
|
313
|
+
function buildNewFile(key, value, description) {
|
|
314
|
+
const lines = [
|
|
315
|
+
'# This file is managed by envseal. It contains real secrets.',
|
|
316
|
+
'# Do NOT commit it to version control.',
|
|
317
|
+
];
|
|
318
|
+
if (description !== undefined && description.length > 0) {
|
|
319
|
+
lines.push(`# ${sanitizeComment(description)}`);
|
|
320
|
+
}
|
|
321
|
+
lines.push(`${key}=${renderValue(value)}`);
|
|
322
|
+
return `${lines.join('\n')}\n`;
|
|
323
|
+
}
|
|
324
|
+
function buildAppendLines(key, value, description) {
|
|
325
|
+
const lines = [];
|
|
326
|
+
if (description !== undefined && description.length > 0) {
|
|
327
|
+
lines.push({ kind: 'comment', text: `# ${sanitizeComment(description)}` });
|
|
328
|
+
}
|
|
329
|
+
lines.push({
|
|
330
|
+
kind: 'assignment',
|
|
331
|
+
text: `${key}=${renderValue(value)}`,
|
|
332
|
+
key,
|
|
333
|
+
value,
|
|
334
|
+
quote: null,
|
|
335
|
+
exported: false,
|
|
336
|
+
lead: '',
|
|
337
|
+
prefix: '',
|
|
338
|
+
trailing: '',
|
|
339
|
+
});
|
|
340
|
+
return lines;
|
|
341
|
+
}
|
|
342
|
+
export function setDotenvValue(paths, key, value, options) {
|
|
343
|
+
// Outside the try: SEP_GITIGNORE_UNSAFE is a refusal, not a write failure.
|
|
344
|
+
assertGitSafe(paths, options?.allowUnsafe);
|
|
345
|
+
try {
|
|
346
|
+
const text = readFileIfPresent(paths.dotenv);
|
|
347
|
+
if (text === null) {
|
|
348
|
+
atomicWrite(paths, paths.dotenv, buildNewFile(key, value, options?.description));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const parsed = parseDotenv(text);
|
|
352
|
+
let lastIndex = -1;
|
|
353
|
+
parsed.lines.forEach((line, index) => {
|
|
354
|
+
if (line.kind === 'assignment' && line.key === key)
|
|
355
|
+
lastIndex = index;
|
|
356
|
+
});
|
|
357
|
+
if (lastIndex >= 0) {
|
|
358
|
+
const existing = parsed.lines[lastIndex];
|
|
359
|
+
if (existing && existing.kind === 'assignment') {
|
|
360
|
+
parsed.lines[lastIndex] = rebuildAssignment(existing, value);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
parsed.lines.push(...buildAppendLines(key, value, options?.description));
|
|
365
|
+
}
|
|
366
|
+
atomicWrite(paths, paths.dotenv, serializeDotenv(parsed));
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
throw asSinkWriteError(error, paths.dotenv);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
export function removeDotenvKey(paths, key, options) {
|
|
373
|
+
assertGitSafe(paths, options?.allowUnsafe);
|
|
374
|
+
try {
|
|
375
|
+
const parsed = parseDotenv(readFileIfPresent(paths.dotenv) ?? '');
|
|
376
|
+
const before = parsed.lines.length;
|
|
377
|
+
parsed.lines = parsed.lines.filter((line) => !(line.kind === 'assignment' && line.key === key));
|
|
378
|
+
const removed = parsed.lines.length !== before;
|
|
379
|
+
if (removed) {
|
|
380
|
+
atomicWrite(paths, paths.dotenv, serializeDotenv(parsed));
|
|
381
|
+
}
|
|
382
|
+
return removed;
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
throw asSinkWriteError(error, paths.dotenv);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
export class DotenvSink {
|
|
389
|
+
id = 'dotenv';
|
|
390
|
+
async available(_paths) {
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
async read(paths, key) {
|
|
394
|
+
const values = readDotenv(paths);
|
|
395
|
+
const value = values[key];
|
|
396
|
+
if (value === undefined)
|
|
397
|
+
return null;
|
|
398
|
+
return asSecret(Buffer.from(value, 'utf8'));
|
|
399
|
+
}
|
|
400
|
+
async write(paths, key, value, options) {
|
|
401
|
+
setDotenvValue(paths, key, unsafeSecretToUtf8(value), options);
|
|
402
|
+
}
|
|
403
|
+
async remove(paths, key, options) {
|
|
404
|
+
return removeDotenvKey(paths, key, options);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
//# sourceMappingURL=dotenv.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SecretValue } from '@envseal/protocol';
|
|
2
|
+
import type { ProjectPaths } from '../paths.js';
|
|
3
|
+
import type { Sink } from './types.js';
|
|
4
|
+
declare class KeychainSink implements Sink {
|
|
5
|
+
readonly id = "keychain";
|
|
6
|
+
available(): Promise<boolean>;
|
|
7
|
+
read(paths: ProjectPaths, key: string): Promise<SecretValue | null>;
|
|
8
|
+
/**
|
|
9
|
+
* Decrypt the DPAPI blob write() left at creds\<KEY>. Absent file means the
|
|
10
|
+
* value is not stored (null); anything present-but-unreadable is a loud
|
|
11
|
+
* error, never a silent null — a corrupt blob pretending to be "absent"
|
|
12
|
+
* would send ensure() back to the prompt instead of telling the user their
|
|
13
|
+
* credential store needs attention.
|
|
14
|
+
*/
|
|
15
|
+
private readWindows;
|
|
16
|
+
write(_paths: ProjectPaths, key: string, value: SecretValue): Promise<void>;
|
|
17
|
+
remove(paths: ProjectPaths, key: string): Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
export declare const keychainSink: KeychainSink;
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=keychain.d.ts.map
|