@jondotsoy/envs 0.1.7 → 0.1.9-alpha.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/README.md +89 -5
- package/SECURITY.md +15 -3
- package/envs.js +1217 -259
- package/package.json +1 -1
- package/ui/chunk-98w9n6ns.js +239 -0
- package/ui/chunk-tnd0q5st.css +1 -0
- package/ui/index.html +14 -0
package/envs.js
CHANGED
|
@@ -1,40 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
var __esm = (fn, res, err) => () => {
|
|
4
|
+
if (fn)
|
|
5
|
+
try {
|
|
6
|
+
res = fn(fn = 0);
|
|
7
|
+
} catch (e) {
|
|
8
|
+
err = [e];
|
|
9
|
+
}
|
|
10
|
+
if (err)
|
|
11
|
+
throw err[0];
|
|
12
|
+
return res;
|
|
13
|
+
};
|
|
9
14
|
|
|
10
15
|
// src/lint-rules.ts
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"admin",
|
|
21
|
-
"root",
|
|
22
|
-
"test",
|
|
23
|
-
"default",
|
|
24
|
-
"example",
|
|
25
|
-
"123456",
|
|
26
|
-
"12345678",
|
|
27
|
-
"qwerty",
|
|
28
|
-
"todo",
|
|
29
|
-
"xxx"
|
|
30
|
-
]);
|
|
31
|
-
var SECRET_PATTERNS = [
|
|
32
|
-
[/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
|
|
33
|
-
[/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
|
|
34
|
-
[/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
|
|
35
|
-
[/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
|
|
36
|
-
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
|
|
37
|
-
];
|
|
16
|
+
function dotenvProblem(key, text) {
|
|
17
|
+
if (!/^[\w.-]+$/.test(key)) {
|
|
18
|
+
return "the name is not a valid variable name (only letters, digits, `_`, `.` and `-`)";
|
|
19
|
+
}
|
|
20
|
+
if (/[\r\n]/.test(text)) {
|
|
21
|
+
return "the value has a line break, which a .env file cannot hold safely (write it on one line, with `\\n` escapes if the application expects them)";
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
38
25
|
function isSensitiveKey(key) {
|
|
39
26
|
return SENSITIVE_KEY.test(key);
|
|
40
27
|
}
|
|
@@ -74,223 +61,747 @@ function parseUrl(text) {
|
|
|
74
61
|
local: /^(localhost|127\.\d+\.\d+\.\d+|::1|0\.0\.0\.0)$/.test(hostname)
|
|
75
62
|
};
|
|
76
63
|
}
|
|
77
|
-
var valuesFile = ".envs/values.yml";
|
|
78
64
|
function envFiles(ctx) {
|
|
79
65
|
return [
|
|
80
66
|
{ location: valuesFile, mode: ctx.valuesMode },
|
|
67
|
+
...ctx.hasHistory ? [{ location: historyFile, mode: ctx.historyMode }] : [],
|
|
81
68
|
...ctx.dotenvs.map((d) => ({ location: `${d.name}/.env`, mode: d.mode }))
|
|
82
69
|
];
|
|
83
70
|
}
|
|
84
|
-
var
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
71
|
+
var SENSITIVE_KEY, WEAK_VALUES, SECRET_PATTERNS, valuesFile = ".envs/values.yml", historyFile = ".envs/history.ndjson", rules;
|
|
72
|
+
var init_lint_rules = __esm(() => {
|
|
73
|
+
SENSITIVE_KEY = /(^|_)(SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIALS?|AUTH|SALT|SIGNING)(_|$)/i;
|
|
74
|
+
WEAK_VALUES = new Set([
|
|
75
|
+
"",
|
|
76
|
+
"changeme",
|
|
77
|
+
"change-me",
|
|
78
|
+
"change_me",
|
|
79
|
+
"password",
|
|
80
|
+
"passw0rd",
|
|
81
|
+
"secret",
|
|
82
|
+
"admin",
|
|
83
|
+
"root",
|
|
84
|
+
"test",
|
|
85
|
+
"default",
|
|
86
|
+
"example",
|
|
87
|
+
"123456",
|
|
88
|
+
"12345678",
|
|
89
|
+
"qwerty",
|
|
90
|
+
"todo",
|
|
91
|
+
"xxx"
|
|
92
|
+
]);
|
|
93
|
+
SECRET_PATTERNS = [
|
|
94
|
+
[/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
|
|
95
|
+
[/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
|
|
96
|
+
[/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
|
|
97
|
+
[/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
|
|
98
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
|
|
99
|
+
];
|
|
100
|
+
rules = [
|
|
101
|
+
{
|
|
102
|
+
id: "tracked-values",
|
|
103
|
+
description: "values.yml is tracked by git.",
|
|
104
|
+
async check(ctx) {
|
|
105
|
+
if (!await ctx.isTracked(ctx.root, valuesFile))
|
|
106
|
+
return [];
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
location: valuesFile,
|
|
110
|
+
message: "values.yml is tracked by git, so its secrets are in the history. Run `git rm --cached .envs/values.yml`."
|
|
111
|
+
}
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "unignored-values",
|
|
117
|
+
description: "values.yml is neither tracked nor ignored by git.",
|
|
118
|
+
async check(ctx) {
|
|
119
|
+
if (await ctx.isTracked(ctx.root, valuesFile))
|
|
120
|
+
return [];
|
|
121
|
+
if (await ctx.isIgnored(ctx.root, valuesFile))
|
|
122
|
+
return [];
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
location: valuesFile,
|
|
126
|
+
message: "values.yml is not ignored by git and could be committed by accident. Add `*` to .envs/.gitignore."
|
|
127
|
+
}
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "open-permissions",
|
|
133
|
+
description: "values.yml or the history is readable by other users.",
|
|
134
|
+
check: (ctx) => [
|
|
135
|
+
{ file: valuesFile, mode: ctx.valuesMode },
|
|
136
|
+
...ctx.hasHistory ? [{ file: historyFile, mode: ctx.historyMode }] : []
|
|
137
|
+
].filter(({ mode }) => mode !== undefined && (mode & 63) !== 0).map(({ file, mode }) => ({
|
|
138
|
+
location: file,
|
|
139
|
+
message: `${file.slice(".envs/".length)} is readable by other users (mode ${(mode & 511).toString(8)}). Run \`chmod 600 ${file}\`.`
|
|
140
|
+
}))
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "executable-files",
|
|
144
|
+
description: "values.yml or a worktree's .env has execute permission.",
|
|
145
|
+
check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 73) !== 0).map((f) => ({
|
|
146
|
+
location: f.location,
|
|
147
|
+
message: `The file is executable (mode ${(f.mode & 511).toString(8)}); env files are data and must not be. Run \`chmod -x\` on it.`
|
|
148
|
+
}))
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: "writable-files",
|
|
152
|
+
description: "values.yml or a worktree's .env is writable by other users.",
|
|
153
|
+
check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 18) !== 0).map((f) => ({
|
|
154
|
+
location: f.location,
|
|
155
|
+
message: `The file is writable by group or other users (mode ${(f.mode & 511).toString(8)}), who could change your environment. Run \`chmod go-w\` on it.`
|
|
156
|
+
}))
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "tracked-history",
|
|
160
|
+
description: "The history (.envs/history.ndjson) is tracked by git.",
|
|
161
|
+
async check(ctx) {
|
|
162
|
+
if (!ctx.hasHistory || !await ctx.isTracked(ctx.root, historyFile))
|
|
163
|
+
return [];
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
location: historyFile,
|
|
167
|
+
message: "The history is tracked by git, and it holds the previous and the new value of every variable, secrets included. Run `git rm --cached .envs/history.ndjson`."
|
|
168
|
+
}
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "unignored-history",
|
|
174
|
+
description: "The history (.envs/history.ndjson) is neither tracked nor ignored by git.",
|
|
175
|
+
async check(ctx) {
|
|
176
|
+
if (!ctx.hasHistory)
|
|
177
|
+
return [];
|
|
178
|
+
if (await ctx.isTracked(ctx.root, historyFile))
|
|
179
|
+
return [];
|
|
180
|
+
if (await ctx.isIgnored(ctx.root, historyFile))
|
|
181
|
+
return [];
|
|
182
|
+
return [
|
|
183
|
+
{
|
|
184
|
+
location: historyFile,
|
|
185
|
+
message: "The history is not ignored by git and could be committed by accident, with its secrets. Add `*` to .envs/.gitignore."
|
|
186
|
+
}
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: "tracked-dotenv",
|
|
192
|
+
description: "A worktree's .env is tracked by git.",
|
|
193
|
+
async check(ctx) {
|
|
194
|
+
const found = [];
|
|
195
|
+
for (const { name, path } of ctx.dotenvs) {
|
|
196
|
+
if (!await ctx.isTracked(path, ".env"))
|
|
197
|
+
continue;
|
|
198
|
+
found.push({
|
|
199
|
+
location: `${name}/.env`,
|
|
200
|
+
message: "The .env is tracked by git. Run `git rm --cached .env` and add it to .gitignore."
|
|
201
|
+
});
|
|
111
202
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "unignored-dotenv",
|
|
208
|
+
description: "A worktree's .env is neither tracked nor ignored by git.",
|
|
209
|
+
async check(ctx) {
|
|
210
|
+
const found = [];
|
|
211
|
+
for (const { name, path } of ctx.dotenvs) {
|
|
212
|
+
if (await ctx.isTracked(path, ".env"))
|
|
213
|
+
continue;
|
|
214
|
+
if (await ctx.isIgnored(path, ".env"))
|
|
215
|
+
continue;
|
|
216
|
+
found.push({
|
|
217
|
+
location: `${name}/.env`,
|
|
218
|
+
message: "The .env is not ignored by git. Add `.env` to .gitignore."
|
|
219
|
+
});
|
|
125
220
|
}
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
{
|
|
130
|
-
id: "executable-files",
|
|
131
|
-
description: "values.yml or a worktree's .env has execute permission.",
|
|
132
|
-
check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 73) !== 0).map((f) => ({
|
|
133
|
-
location: f.location,
|
|
134
|
-
message: `The file is executable (mode ${(f.mode & 511).toString(8)}); env files are data and must not be. Run \`chmod -x\` on it.`
|
|
135
|
-
}))
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
id: "writable-files",
|
|
139
|
-
description: "values.yml or a worktree's .env is writable by other users.",
|
|
140
|
-
check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 18) !== 0).map((f) => ({
|
|
141
|
-
location: f.location,
|
|
142
|
-
message: `The file is writable by group or other users (mode ${(f.mode & 511).toString(8)}), who could change your environment. Run \`chmod go-w\` on it.`
|
|
143
|
-
}))
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
id: "tracked-dotenv",
|
|
147
|
-
description: "A worktree's .env is tracked by git.",
|
|
148
|
-
async check(ctx) {
|
|
149
|
-
const found = [];
|
|
150
|
-
for (const { name, path } of ctx.dotenvs) {
|
|
151
|
-
if (!await ctx.isTracked(path, ".env"))
|
|
152
|
-
continue;
|
|
153
|
-
found.push({
|
|
154
|
-
location: `${name}/.env`,
|
|
155
|
-
message: "The .env is tracked by git. Run `git rm --cached .env` and add it to .gitignore."
|
|
156
|
-
});
|
|
221
|
+
return found;
|
|
157
222
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "weak-secret",
|
|
226
|
+
description: "A sensitive variable is empty or has an easily guessed value.",
|
|
227
|
+
check: (ctx) => entries(ctx).filter((e) => isSensitiveKey(e.key) && WEAK_VALUES.has(e.text.trim().toLowerCase())).map((e) => ({
|
|
228
|
+
location: e.location,
|
|
229
|
+
message: `${e.key} has an empty or easily guessed value.`
|
|
230
|
+
}))
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "shared-secret",
|
|
234
|
+
description: "A sensitive variable is in defaults, so it goes to every worktree using that profile.",
|
|
235
|
+
check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
|
|
236
|
+
location: e.location,
|
|
237
|
+
message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
|
|
238
|
+
}))
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: "secret-pattern",
|
|
242
|
+
description: "A value has the format of a well-known secret.",
|
|
243
|
+
check: (ctx) => entries(ctx).flatMap((e) => SECRET_PATTERNS.filter(([pattern]) => pattern.test(e.text)).map(([, name]) => ({
|
|
244
|
+
location: e.location,
|
|
245
|
+
message: `${e.key} looks like ${name}.`
|
|
246
|
+
})))
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "url-credentials",
|
|
250
|
+
description: "A remote URL embeds a password.",
|
|
251
|
+
check: (ctx) => entries(ctx).filter((e) => {
|
|
252
|
+
const url = parseUrl(e.text);
|
|
253
|
+
return url?.hasPassword && !url.local;
|
|
254
|
+
}).map((e) => ({
|
|
255
|
+
location: e.location,
|
|
256
|
+
message: `${e.key} embeds a password in its URL.`
|
|
257
|
+
}))
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "insecure-url",
|
|
261
|
+
description: "A remote URL uses an unencrypted protocol.",
|
|
262
|
+
check: (ctx) => entries(ctx).filter((e) => {
|
|
263
|
+
const url = parseUrl(e.text);
|
|
264
|
+
return url && !url.local && ["http", "ws", "ftp"].includes(url.scheme);
|
|
265
|
+
}).map((e) => ({
|
|
266
|
+
location: e.location,
|
|
267
|
+
message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
|
|
268
|
+
}))
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: "unknown-profile",
|
|
272
|
+
description: "A worktree uses a profile that is not defined.",
|
|
273
|
+
check: ({ values }) => Object.entries(values.uses ?? {}).filter(([, profile]) => profile !== "default" && !Object.hasOwn(values.profiles ?? {}, profile)).map(([worktree, profile]) => ({
|
|
274
|
+
location: `uses.${worktree}`,
|
|
275
|
+
message: `Profile "${profile}" is not defined under profiles; \`envs edit\` creates it from default.`
|
|
276
|
+
}))
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
id: "unsafe-dotenv-value",
|
|
280
|
+
description: "A variable name or value cannot be written to a .env file safely (line break, invalid name).",
|
|
281
|
+
check: (ctx) => entries(ctx).flatMap((e) => {
|
|
282
|
+
const problem = dotenvProblem(e.key, e.text);
|
|
283
|
+
return problem ? [{ location: e.location, message: `${JSON.stringify(e.key)}: ${problem}.` }] : [];
|
|
284
|
+
})
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
id: "unknown-worktree",
|
|
288
|
+
description: "values.yml has values for a worktree that git does not list (deleted, or its branch was renamed).",
|
|
289
|
+
check({ values, worktrees }) {
|
|
290
|
+
if (!worktrees)
|
|
291
|
+
return [];
|
|
292
|
+
const known = new Set(worktrees);
|
|
293
|
+
const found = [];
|
|
294
|
+
const note = (location, name) => found.push({
|
|
295
|
+
location,
|
|
296
|
+
message: `"${name}" is not a git worktree anymore (it was deleted, or its branch renamed). Its values stay in values.yml but nothing uses them: remove them with \`envs edit\`.`
|
|
174
297
|
});
|
|
298
|
+
for (const name of Object.keys(values.uses ?? {}))
|
|
299
|
+
if (!known.has(name))
|
|
300
|
+
note(`uses.${name}`, name);
|
|
301
|
+
const scan = (prefix, envs) => {
|
|
302
|
+
for (const [variable, byWorktree] of Object.entries(envs ?? {})) {
|
|
303
|
+
for (const name of Object.keys(byWorktree ?? {}))
|
|
304
|
+
if (!known.has(name))
|
|
305
|
+
note(`${prefix}envs.${variable}.${name}`, name);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
scan("", values.envs);
|
|
309
|
+
for (const [profile, section] of Object.entries(values.profiles ?? {}))
|
|
310
|
+
scan(`profiles.${profile}.`, section?.envs);
|
|
311
|
+
return found;
|
|
175
312
|
}
|
|
176
|
-
return found;
|
|
177
313
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
id: "weak-secret",
|
|
181
|
-
description: "A sensitive variable is empty or has an easily guessed value.",
|
|
182
|
-
check: (ctx) => entries(ctx).filter((e) => isSensitiveKey(e.key) && WEAK_VALUES.has(e.text.trim().toLowerCase())).map((e) => ({
|
|
183
|
-
location: e.location,
|
|
184
|
-
message: `${e.key} has an empty or easily guessed value.`
|
|
185
|
-
}))
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
id: "shared-secret",
|
|
189
|
-
description: "A sensitive variable is in defaults, so it goes to every worktree using that profile.",
|
|
190
|
-
check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
|
|
191
|
-
location: e.location,
|
|
192
|
-
message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
|
|
193
|
-
}))
|
|
194
|
-
},
|
|
195
|
-
{
|
|
196
|
-
id: "secret-pattern",
|
|
197
|
-
description: "A value has the format of a well-known secret.",
|
|
198
|
-
check: (ctx) => entries(ctx).flatMap((e) => SECRET_PATTERNS.filter(([pattern]) => pattern.test(e.text)).map(([, name]) => ({
|
|
199
|
-
location: e.location,
|
|
200
|
-
message: `${e.key} looks like ${name}.`
|
|
201
|
-
})))
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
id: "url-credentials",
|
|
205
|
-
description: "A remote URL embeds a password.",
|
|
206
|
-
check: (ctx) => entries(ctx).filter((e) => {
|
|
207
|
-
const url = parseUrl(e.text);
|
|
208
|
-
return url?.hasPassword && !url.local;
|
|
209
|
-
}).map((e) => ({
|
|
210
|
-
location: e.location,
|
|
211
|
-
message: `${e.key} embeds a password in its URL.`
|
|
212
|
-
}))
|
|
213
|
-
},
|
|
214
|
-
{
|
|
215
|
-
id: "insecure-url",
|
|
216
|
-
description: "A remote URL uses an unencrypted protocol.",
|
|
217
|
-
check: (ctx) => entries(ctx).filter((e) => {
|
|
218
|
-
const url = parseUrl(e.text);
|
|
219
|
-
return url && !url.local && ["http", "ws", "ftp"].includes(url.scheme);
|
|
220
|
-
}).map((e) => ({
|
|
221
|
-
location: e.location,
|
|
222
|
-
message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
|
|
223
|
-
}))
|
|
224
|
-
},
|
|
225
|
-
{
|
|
226
|
-
id: "unknown-profile",
|
|
227
|
-
description: "A worktree uses a profile that is not defined.",
|
|
228
|
-
check: ({ values }) => Object.entries(values.uses ?? {}).filter(([, profile]) => profile !== "default" && !Object.hasOwn(values.profiles ?? {}, profile)).map(([worktree, profile]) => ({
|
|
229
|
-
location: `uses.${worktree}`,
|
|
230
|
-
message: `Profile "${profile}" is not defined under profiles; \`envs edit\` creates it from default.`
|
|
231
|
-
}))
|
|
232
|
-
}
|
|
233
|
-
];
|
|
314
|
+
];
|
|
315
|
+
});
|
|
234
316
|
|
|
235
|
-
// src/
|
|
236
|
-
|
|
317
|
+
// src/history.ts
|
|
318
|
+
function cloneProfile(profile) {
|
|
319
|
+
const copy = emptyProfile();
|
|
320
|
+
for (const [key, value] of Object.entries(profile.defaults))
|
|
321
|
+
put(copy.defaults, key, value);
|
|
322
|
+
for (const [key, byWorktree] of Object.entries(profile.envs)) {
|
|
323
|
+
const target = dict();
|
|
324
|
+
for (const [worktree, value] of Object.entries(byWorktree))
|
|
325
|
+
put(target, worktree, value);
|
|
326
|
+
put(copy.envs, key, target);
|
|
327
|
+
}
|
|
328
|
+
return copy;
|
|
329
|
+
}
|
|
330
|
+
function cloneState(state) {
|
|
331
|
+
const copy = emptyState();
|
|
332
|
+
for (const [worktree, profile] of Object.entries(state.uses))
|
|
333
|
+
put(copy.uses, worktree, profile);
|
|
334
|
+
for (const [name, profile] of Object.entries(state.profiles))
|
|
335
|
+
put(copy.profiles, name, cloneProfile(profile));
|
|
336
|
+
return copy;
|
|
337
|
+
}
|
|
338
|
+
function diffValues(before, after) {
|
|
339
|
+
const changes = [];
|
|
340
|
+
const compare = (path, a, b) => {
|
|
341
|
+
if (a !== b)
|
|
342
|
+
changes.push({ path, ...a !== undefined && { from: a }, ...b !== undefined && { to: b } });
|
|
343
|
+
};
|
|
344
|
+
for (const w of keys(before.uses, after.uses))
|
|
345
|
+
compare(["uses", w], own(before.uses, w), own(after.uses, w));
|
|
346
|
+
for (const p of keys(before.profiles, after.profiles)) {
|
|
347
|
+
const a = own(before.profiles, p);
|
|
348
|
+
const b = own(after.profiles, p);
|
|
349
|
+
if (p !== DEFAULT_PROFILE && !a && b)
|
|
350
|
+
changes.push({ path: ["profiles", p], to: true });
|
|
351
|
+
for (const k of keys(a?.defaults, b?.defaults)) {
|
|
352
|
+
compare(["profiles", p, "defaults", k], own(a?.defaults, k), own(b?.defaults, k));
|
|
353
|
+
}
|
|
354
|
+
for (const k of keys(a?.envs, b?.envs)) {
|
|
355
|
+
const ea = own(a?.envs, k);
|
|
356
|
+
const eb = own(b?.envs, k);
|
|
357
|
+
for (const w of keys(ea, eb))
|
|
358
|
+
compare(["profiles", p, "envs", k, w], own(ea, w), own(eb, w));
|
|
359
|
+
}
|
|
360
|
+
if (p !== DEFAULT_PROFILE && a && !b)
|
|
361
|
+
changes.push({ path: ["profiles", p], from: true });
|
|
362
|
+
}
|
|
363
|
+
return changes;
|
|
364
|
+
}
|
|
365
|
+
function applyChanges(state, changes) {
|
|
366
|
+
for (const { path, to } of changes) {
|
|
367
|
+
const [section, ...rest] = path;
|
|
368
|
+
if (section === "uses") {
|
|
369
|
+
const [worktree] = rest;
|
|
370
|
+
if (to === undefined)
|
|
371
|
+
delete state.uses[worktree];
|
|
372
|
+
else
|
|
373
|
+
put(state.uses, worktree, String(to));
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const [profileName, kind, variable, worktree] = rest;
|
|
377
|
+
if (kind === undefined) {
|
|
378
|
+
if (to === undefined)
|
|
379
|
+
delete state.profiles[profileName];
|
|
380
|
+
else if (!own(state.profiles, profileName))
|
|
381
|
+
put(state.profiles, profileName, emptyProfile());
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
let profile = own(state.profiles, profileName);
|
|
385
|
+
if (!profile)
|
|
386
|
+
put(state.profiles, profileName, profile = emptyProfile());
|
|
387
|
+
if (kind === "defaults") {
|
|
388
|
+
if (to === undefined)
|
|
389
|
+
delete profile.defaults[variable];
|
|
390
|
+
else
|
|
391
|
+
put(profile.defaults, variable, to);
|
|
392
|
+
} else {
|
|
393
|
+
let byWorktree = own(profile.envs, variable);
|
|
394
|
+
if (!byWorktree)
|
|
395
|
+
put(profile.envs, variable, byWorktree = dict());
|
|
396
|
+
if (to === undefined)
|
|
397
|
+
delete byWorktree[worktree];
|
|
398
|
+
else
|
|
399
|
+
put(byWorktree, worktree, to);
|
|
400
|
+
if (Object.keys(byWorktree).length === 0)
|
|
401
|
+
delete profile.envs[variable];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function findHistoryPoint(points, hash) {
|
|
406
|
+
const matches = hash === "" ? [] : points.filter((point) => point.hash.startsWith(hash));
|
|
407
|
+
if (matches.length === 1)
|
|
408
|
+
return { point: matches[0] };
|
|
409
|
+
return { error: matches.length === 0 ? "none" : "ambiguous", matches: matches.length };
|
|
410
|
+
}
|
|
411
|
+
function withDefaults(state) {
|
|
412
|
+
for (const name of [DEFAULT_PROFILE, ...Object.values(state.uses)]) {
|
|
413
|
+
if (!own(state.profiles, name))
|
|
414
|
+
put(state.profiles, name, emptyProfile());
|
|
415
|
+
}
|
|
416
|
+
return state;
|
|
417
|
+
}
|
|
418
|
+
function stateAfter(points, target) {
|
|
419
|
+
const state = emptyState();
|
|
420
|
+
for (const point of points) {
|
|
421
|
+
applyChanges(state, point.changes);
|
|
422
|
+
if (point === target)
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
withDefaults(state);
|
|
426
|
+
return state;
|
|
427
|
+
}
|
|
428
|
+
var DEFAULT_PROFILE = "default", REDACTED_PREFIX = "envs:redacted:", isRedacted = (value) => typeof value === "string" && value.startsWith(REDACTED_PREFIX), dict = () => Object.create(null), own = (map, key) => map !== undefined && Object.hasOwn(map, key) ? map[key] : undefined, put = (map, key, value) => void Object.defineProperty(map, key, { value, writable: true, enumerable: true, configurable: true }), emptyProfile = () => ({ defaults: dict(), envs: dict() }), emptyState = () => ({ uses: dict(), profiles: dict() }), keys = (a, b) => [...new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])];
|
|
237
429
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
430
|
+
// src/ui/server.ts
|
|
431
|
+
import { join } from "path";
|
|
432
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
433
|
+
import { readdir } from "fs/promises";
|
|
434
|
+
function parseBody(body) {
|
|
435
|
+
if (!isRecord(body) || !isRecord(body.uses) || !isRecord(body.profiles))
|
|
436
|
+
return;
|
|
437
|
+
if (!Object.values(body.uses).every((v) => typeof v === "string"))
|
|
438
|
+
return;
|
|
439
|
+
for (const profile of Object.values(body.profiles)) {
|
|
440
|
+
if (!isRecord(profile) || !isRecord(profile.defaults) || !isRecord(profile.envs))
|
|
441
|
+
return;
|
|
442
|
+
if (!Object.values(profile.defaults).every(isScalar))
|
|
443
|
+
return;
|
|
444
|
+
for (const byWorktree of Object.values(profile.envs)) {
|
|
445
|
+
if (!isRecord(byWorktree) || !Object.values(byWorktree).every(isScalar))
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (body.version !== undefined && typeof body.version !== "string")
|
|
450
|
+
return;
|
|
451
|
+
return { values: body, ...body.version !== undefined && { base: body.version } };
|
|
452
|
+
}
|
|
453
|
+
function parseRestoreBody(body) {
|
|
454
|
+
if (!isRecord(body) || typeof body.hash !== "string" || !/^[0-9a-f]{12}$/.test(body.hash))
|
|
455
|
+
return;
|
|
456
|
+
return body.hash;
|
|
457
|
+
}
|
|
458
|
+
function capture(ctx) {
|
|
459
|
+
const logs = [];
|
|
460
|
+
const errors = [];
|
|
461
|
+
const captured = {
|
|
462
|
+
...ctx,
|
|
463
|
+
log: (m) => logs.push(m.replace(ANSI, "")),
|
|
464
|
+
error: (m) => errors.push(m.replace(ANSI, ""))
|
|
465
|
+
};
|
|
466
|
+
return { ctx: captured, logs, errors };
|
|
467
|
+
}
|
|
468
|
+
async function loadAssets() {
|
|
469
|
+
const assets = new Map;
|
|
470
|
+
if (await Bun.file(join(import.meta.dir, "app.tsx")).exists()) {
|
|
471
|
+
const result = await Bun.build({ entrypoints: [join(import.meta.dir, "index.html")], target: "browser" });
|
|
472
|
+
if (!result.success)
|
|
473
|
+
throw new AggregateError(result.logs, "Web editor build failed");
|
|
474
|
+
for (const out of result.outputs)
|
|
475
|
+
assets.set(out.path.replace(/^\.\//, ""), out);
|
|
476
|
+
} else {
|
|
477
|
+
const dir = join(import.meta.dir, "ui");
|
|
478
|
+
for (const name of await readdir(dir))
|
|
479
|
+
assets.set(name, Bun.file(join(dir, name)));
|
|
263
480
|
}
|
|
481
|
+
return assets;
|
|
482
|
+
}
|
|
483
|
+
function sameToken(presented, token) {
|
|
484
|
+
if (!presented)
|
|
485
|
+
return false;
|
|
486
|
+
const a = Buffer.from(presented);
|
|
487
|
+
const b = Buffer.from(token);
|
|
488
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
489
|
+
}
|
|
490
|
+
function presentedToken(req) {
|
|
491
|
+
const header = req.headers.get("x-envs-token");
|
|
492
|
+
if (header)
|
|
493
|
+
return header;
|
|
494
|
+
return req.headers.get("cookie")?.split(";").map((c) => c.trim().split("=")).find(([name]) => name === TOKEN_COOKIE)?.[1];
|
|
495
|
+
}
|
|
496
|
+
async function serveUi(ctx) {
|
|
497
|
+
const assets = await loadAssets();
|
|
498
|
+
const token = randomBytes(24).toString("base64url");
|
|
499
|
+
const guard = (handler) => async (req) => {
|
|
500
|
+
const port = server.port;
|
|
501
|
+
const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
502
|
+
if (!hosts.includes(req.headers.get("host") ?? "")) {
|
|
503
|
+
return new Response("Misdirected request", { status: 421 });
|
|
504
|
+
}
|
|
505
|
+
const origin = req.headers.get("origin");
|
|
506
|
+
if (origin !== null && !hosts.some((host) => origin === `http://${host}`)) {
|
|
507
|
+
return new Response("Forbidden origin", { status: 403 });
|
|
508
|
+
}
|
|
509
|
+
const url = new URL(req.url);
|
|
510
|
+
if (url.pathname === "/" && sameToken(url.searchParams.get("token"), token)) {
|
|
511
|
+
return new Response(null, {
|
|
512
|
+
status: 302,
|
|
513
|
+
headers: {
|
|
514
|
+
location: "/",
|
|
515
|
+
"set-cookie": `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (!sameToken(presentedToken(req), token)) {
|
|
520
|
+
return new Response("Unauthorized: open the address printed by `envs edit --ui`.", { status: 401 });
|
|
521
|
+
}
|
|
522
|
+
if (req.method !== "GET" && req.method !== "HEAD" && !req.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
523
|
+
return new Response("Unsupported media type", { status: 415 });
|
|
524
|
+
}
|
|
525
|
+
return handler(req);
|
|
526
|
+
};
|
|
527
|
+
const server = Bun.serve({
|
|
528
|
+
hostname: "127.0.0.1",
|
|
529
|
+
port: 0,
|
|
530
|
+
maxRequestBodySize: MAX_BODY_BYTES,
|
|
531
|
+
routes: {
|
|
532
|
+
"/api/values": {
|
|
533
|
+
GET: guard(async () => Response.json(await loadUiState(ctx))),
|
|
534
|
+
PUT: guard(async (req) => {
|
|
535
|
+
const parsed = parseBody(await req.json().catch(() => {
|
|
536
|
+
return;
|
|
537
|
+
}));
|
|
538
|
+
if (!parsed)
|
|
539
|
+
return Response.json({ error: "Invalid values" }, { status: 400 });
|
|
540
|
+
const { ctx: captured, logs, errors } = capture(ctx);
|
|
541
|
+
const { created, code, conflict } = await saveUiState(captured, parsed.values, { base: parsed.base });
|
|
542
|
+
return Response.json({ ok: code === 0, created, logs, errors }, { status: code === 0 ? 200 : conflict ? 409 : 500 });
|
|
543
|
+
})
|
|
544
|
+
},
|
|
545
|
+
"/api/history": {
|
|
546
|
+
GET: guard(async () => Response.json(await loadUiHistory(ctx)))
|
|
547
|
+
},
|
|
548
|
+
"/api/restore": {
|
|
549
|
+
POST: guard(async (req) => {
|
|
550
|
+
const hash = parseRestoreBody(await req.json().catch(() => {
|
|
551
|
+
return;
|
|
552
|
+
}));
|
|
553
|
+
if (!hash)
|
|
554
|
+
return Response.json({ error: "Invalid hash" }, { status: 400 });
|
|
555
|
+
const root = await findRoot(ctx.cwd);
|
|
556
|
+
if (!await historyEnabled(root))
|
|
557
|
+
return Response.json({ error: "The history is off" }, { status: 409 });
|
|
558
|
+
if (!(await readHistoryPoints(root)).some((point) => point.hash === hash)) {
|
|
559
|
+
return Response.json({ error: "Unknown history point" }, { status: 404 });
|
|
560
|
+
}
|
|
561
|
+
const { ctx: captured, logs, errors } = capture(ctx);
|
|
562
|
+
const code = await restore(captured, hash);
|
|
563
|
+
return Response.json({ ok: code === 0, created: [], logs, errors }, { status: code === 0 ? 200 : 500 });
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
fetch: guard((req) => {
|
|
568
|
+
const path = new URL(req.url).pathname.slice(1) || "index.html";
|
|
569
|
+
const asset = assets.get(path);
|
|
570
|
+
return asset ? new Response(asset) : new Response("Not found", { status: 404 });
|
|
571
|
+
})
|
|
572
|
+
});
|
|
573
|
+
const url = `http://127.0.0.1:${server.port}/?token=${token}`;
|
|
574
|
+
ctx.log(`Web editor running at ${url} (Ctrl+C to stop)`);
|
|
575
|
+
await ctx.openUrl?.(url);
|
|
576
|
+
await (ctx.waitForExit ?? (() => new Promise(() => {})))();
|
|
577
|
+
await server.stop(true);
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
580
|
+
var ANSI, isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v), isScalar = (v) => ["string", "number", "boolean"].includes(typeof v), TOKEN_COOKIE = "envs_token", MAX_BODY_BYTES;
|
|
581
|
+
var init_server = __esm(() => {
|
|
582
|
+
init_envs();
|
|
583
|
+
ANSI = /\x1b\[[0-9;]*m/g;
|
|
584
|
+
MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
264
585
|
});
|
|
265
|
-
|
|
266
|
-
|
|
586
|
+
|
|
587
|
+
// src/envs.ts
|
|
588
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
589
|
+
import { chmod, mkdir, open, rename, unlink, writeFile } from "fs/promises";
|
|
590
|
+
import { basename, dirname, join as join2 } from "path";
|
|
591
|
+
import { stat } from "fs/promises";
|
|
592
|
+
import { createHash, createHmac, randomBytes as randomBytes2 } from "crypto";
|
|
593
|
+
import { appendFile, truncate } from "fs/promises";
|
|
594
|
+
var {$ } = globalThis.Bun;
|
|
267
595
|
function normalizeValues(raw) {
|
|
268
|
-
const profiles =
|
|
596
|
+
const profiles = dict();
|
|
269
597
|
const add = (name, profile) => {
|
|
270
|
-
const target = profiles[name] ??= { defaults:
|
|
598
|
+
const target = profiles[name] ??= { defaults: dict(), envs: dict() };
|
|
271
599
|
Object.assign(target.defaults, profile?.defaults);
|
|
272
600
|
for (const [key, byWorktree] of Object.entries(profile?.envs ?? {})) {
|
|
273
|
-
Object.assign(target.envs[key] ??=
|
|
601
|
+
Object.assign(target.envs[key] ??= dict(), byWorktree);
|
|
274
602
|
}
|
|
275
603
|
};
|
|
276
604
|
add(DEFAULT_PROFILE, { defaults: raw.defaults, envs: raw.envs });
|
|
277
605
|
for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
|
|
278
606
|
add(name, profile);
|
|
279
607
|
}
|
|
280
|
-
|
|
608
|
+
const uses = dict();
|
|
609
|
+
for (const [worktree, profile] of Object.entries(raw.uses ?? {}))
|
|
610
|
+
uses[worktree] = profile;
|
|
611
|
+
return { uses, profiles };
|
|
281
612
|
}
|
|
282
613
|
function materializeProfiles(values) {
|
|
283
614
|
const created = [];
|
|
284
615
|
for (const name of Object.values(values.uses).map(String)) {
|
|
285
616
|
if (hasProfile(values, name))
|
|
286
617
|
continue;
|
|
287
|
-
values.profiles[name] =
|
|
618
|
+
values.profiles[name] = cloneProfile(values.profiles[DEFAULT_PROFILE]);
|
|
288
619
|
created.push(name);
|
|
289
620
|
}
|
|
290
621
|
return created;
|
|
291
622
|
}
|
|
623
|
+
async function keepsSecrets(root) {
|
|
624
|
+
return (await readConfig(join2(root, ".envs/config.yml")))["history.secrets"] === true;
|
|
625
|
+
}
|
|
626
|
+
async function historyKey(root) {
|
|
627
|
+
const path = join2(root, ".envs/history.key");
|
|
628
|
+
const read = async () => (await Bun.file(path).text()).trim();
|
|
629
|
+
if (await Bun.file(path).exists())
|
|
630
|
+
return await read();
|
|
631
|
+
await mkdir(join2(root, ".envs"), { recursive: true, mode: 448 });
|
|
632
|
+
try {
|
|
633
|
+
await writeFile(path, randomBytes2(32).toString("hex") + `
|
|
634
|
+
`, { mode: 384, flag: "wx" });
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (error.code !== "EEXIST")
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
return await read();
|
|
640
|
+
}
|
|
641
|
+
async function redactor(root) {
|
|
642
|
+
if (await keepsSecrets(root))
|
|
643
|
+
return { mark: (_, value) => value, state: (state) => cloneState(state) };
|
|
644
|
+
const key = await historyKey(root);
|
|
645
|
+
const mark = (name, value) => REDACTED_PREFIX + createHmac("sha256", key).update(`${name}\x00${value}`).digest("hex").slice(0, 16);
|
|
646
|
+
const one = (name, value) => typeof value === "string" && isRedacted(value) ? value : isSecretEntry(name, String(value)) ? mark(name, String(value)) : value;
|
|
647
|
+
return {
|
|
648
|
+
mark,
|
|
649
|
+
state(state) {
|
|
650
|
+
const copy = cloneState(state);
|
|
651
|
+
for (const profile of Object.values(copy.profiles)) {
|
|
652
|
+
for (const [name, value] of Object.entries(profile.defaults))
|
|
653
|
+
profile.defaults[name] = one(name, value);
|
|
654
|
+
for (const [name, byWorktree] of Object.entries(profile.envs)) {
|
|
655
|
+
for (const [worktree, value] of Object.entries(byWorktree))
|
|
656
|
+
byWorktree[worktree] = one(name, value);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return copy;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function unredact(restored, current, mark) {
|
|
664
|
+
const notes = [];
|
|
665
|
+
for (const [profileName, profile] of Object.entries(restored.profiles)) {
|
|
666
|
+
const now = Object.hasOwn(current.profiles, profileName) ? current.profiles[profileName] : undefined;
|
|
667
|
+
const settle = (map, key, variable, where, currentValue) => {
|
|
668
|
+
const value = map[key];
|
|
669
|
+
if (!isRedacted(value))
|
|
670
|
+
return;
|
|
671
|
+
if (currentValue === undefined) {
|
|
672
|
+
delete map[key];
|
|
673
|
+
notes.push(`${variable} (${where}): not restored, the history does not keep its value`);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
map[key] = currentValue;
|
|
677
|
+
if (mark(variable, String(currentValue)) !== value) {
|
|
678
|
+
notes.push(`${variable} (${where}): kept the current value, the history does not keep the old one`);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
for (const variable of Object.keys(profile.defaults)) {
|
|
682
|
+
const nowValue = now && Object.hasOwn(now.defaults, variable) ? now.defaults[variable] : undefined;
|
|
683
|
+
settle(profile.defaults, variable, variable, `${profileName} defaults`, nowValue);
|
|
684
|
+
}
|
|
685
|
+
for (const [variable, byWorktree] of Object.entries(profile.envs)) {
|
|
686
|
+
const nowByWorktree = now && Object.hasOwn(now.envs, variable) ? now.envs[variable] : undefined;
|
|
687
|
+
for (const worktree of Object.keys(byWorktree)) {
|
|
688
|
+
const nowValue = nowByWorktree && Object.hasOwn(nowByWorktree, worktree) ? nowByWorktree[worktree] : undefined;
|
|
689
|
+
settle(byWorktree, worktree, variable, `${profileName} ${worktree}`, nowValue);
|
|
690
|
+
}
|
|
691
|
+
if (Object.keys(byWorktree).length === 0)
|
|
692
|
+
delete profile.envs[variable];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return notes;
|
|
696
|
+
}
|
|
697
|
+
async function recordHistory(root, before, after, now = () => new Date, warn = () => {}) {
|
|
698
|
+
return await withLock(root, () => recordHistoryLocked(root, before, after, now, warn));
|
|
699
|
+
}
|
|
700
|
+
async function recordHistoryLocked(root, before, after, now, warn) {
|
|
701
|
+
if (!await historyEnabled(root))
|
|
702
|
+
return;
|
|
703
|
+
const historyPath = join2(root, ".envs/history.ndjson");
|
|
704
|
+
const scan = await scanHistory(root);
|
|
705
|
+
if (scan.torn)
|
|
706
|
+
warn(".envs/history.ndjson ended with an incomplete line (an interrupted write): it is ignored.");
|
|
707
|
+
if (scan.damaged.length > 0) {
|
|
708
|
+
warn(`.envs/history.ndjson has damaged lines (${scan.damaged.join(", ")}): they are ignored, so the history may be incomplete.`);
|
|
709
|
+
}
|
|
710
|
+
if (scan.broken.length > 0) {
|
|
711
|
+
warn(`.envs/history.ndjson: the hash chain is broken at ${describeBroken(scan.broken)}. The history may have been altered; \`envs restore\` refuses until it is checked.`);
|
|
712
|
+
}
|
|
713
|
+
const points = scan.points;
|
|
714
|
+
let prev = points.at(-1)?.hash ?? null;
|
|
715
|
+
let repaired = false;
|
|
716
|
+
const append = async (changes, extra = {}) => {
|
|
717
|
+
if (!repaired) {
|
|
718
|
+
repaired = true;
|
|
719
|
+
if (scan.torn)
|
|
720
|
+
await truncate(historyPath, Buffer.byteLength(scan.complete));
|
|
721
|
+
else if (!scan.endsWithNewline)
|
|
722
|
+
await appendFile(historyPath, `
|
|
723
|
+
`);
|
|
724
|
+
}
|
|
725
|
+
const point = { prev, timestamp: now().toISOString(), changes, ...extra };
|
|
726
|
+
const hash = createHash("sha256").update(JSON.stringify(point)).digest("hex").slice(0, 12);
|
|
727
|
+
await appendFile(historyPath, JSON.stringify({ hash, ...point }) + `
|
|
728
|
+
`, { mode: 384 });
|
|
729
|
+
prev = hash;
|
|
730
|
+
return hash;
|
|
731
|
+
};
|
|
732
|
+
const { state: redact } = await redactor(root);
|
|
733
|
+
const redactedBefore = redact(before);
|
|
734
|
+
const redactedAfter = redact(after);
|
|
735
|
+
if (points.length === 0) {
|
|
736
|
+
const changes = diffValues({ uses: {}, profiles: {} }, redactedAfter);
|
|
737
|
+
return changes.length === 0 ? undefined : await append(changes);
|
|
738
|
+
}
|
|
739
|
+
const drift = diffValues(redact(stateAfter(points, points.at(-1))), redactedBefore);
|
|
740
|
+
if (drift.length > 0)
|
|
741
|
+
await append(drift, { resync: true });
|
|
742
|
+
const changes = diffValues(redactedBefore, redactedAfter);
|
|
743
|
+
return changes.length === 0 ? undefined : await append(changes);
|
|
744
|
+
}
|
|
745
|
+
async function redactHistory(ctx) {
|
|
746
|
+
const root = await findRoot(ctx.cwd);
|
|
747
|
+
return await withLock(root, async () => {
|
|
748
|
+
const scan = await scanHistory(root);
|
|
749
|
+
if (scan.points.length === 0 && scan.damaged.length === 0) {
|
|
750
|
+
ctx.log("There is no history to redact.");
|
|
751
|
+
return 0;
|
|
752
|
+
}
|
|
753
|
+
if (scan.damaged.length > 0 || scan.broken.length > 0) {
|
|
754
|
+
ctx.error(".envs/history.ndjson is damaged or its hash chain is broken: check it before rewriting it.");
|
|
755
|
+
return 1;
|
|
756
|
+
}
|
|
757
|
+
const { mark } = await redactor(root);
|
|
758
|
+
let redacted = 0;
|
|
759
|
+
const redactValue = (variable, value) => {
|
|
760
|
+
if (value === undefined || isRedacted(value) || !isSecretEntry(variable, String(value)))
|
|
761
|
+
return value;
|
|
762
|
+
redacted++;
|
|
763
|
+
return mark(variable, String(value));
|
|
764
|
+
};
|
|
765
|
+
let prev = null;
|
|
766
|
+
const lines = [];
|
|
767
|
+
for (const old of scan.points) {
|
|
768
|
+
const changes = old.changes.map((change) => {
|
|
769
|
+
const [section, , kind, variable] = change.path;
|
|
770
|
+
if (section !== "profiles" || kind !== "defaults" && kind !== "envs" || variable === undefined)
|
|
771
|
+
return change;
|
|
772
|
+
const from = redactValue(variable, change.from);
|
|
773
|
+
const to = redactValue(variable, change.to);
|
|
774
|
+
return { path: change.path, ...from !== undefined && { from }, ...to !== undefined && { to } };
|
|
775
|
+
});
|
|
776
|
+
const point = { prev, timestamp: old.timestamp, changes, ...old.resync && { resync: true } };
|
|
777
|
+
const hash = createHash("sha256").update(JSON.stringify(point)).digest("hex").slice(0, 12);
|
|
778
|
+
lines.push(JSON.stringify({ hash, ...point }));
|
|
779
|
+
prev = hash;
|
|
780
|
+
}
|
|
781
|
+
if (redacted === 0) {
|
|
782
|
+
ctx.log("No secrets in clear in the history: nothing to redact.");
|
|
783
|
+
return 0;
|
|
784
|
+
}
|
|
785
|
+
const path = join2(root, ".envs/history.ndjson");
|
|
786
|
+
await writeFile(`${path}.tmp`, lines.join(`
|
|
787
|
+
`) + `
|
|
788
|
+
`, { mode: 384 });
|
|
789
|
+
await rename(`${path}.tmp`, path);
|
|
790
|
+
ctx.log(green(`\u2713 redacted ${redacted} secret value(s) in the history; its hash chain was rewritten (the hashes of its points changed)`));
|
|
791
|
+
return 0;
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
async function historyCommand(ctx, args) {
|
|
795
|
+
if (args[0] === "redact")
|
|
796
|
+
return await redactHistory(ctx);
|
|
797
|
+
ctx.error("Usage: envs history redact");
|
|
798
|
+
return 1;
|
|
799
|
+
}
|
|
800
|
+
async function historyEnabled(root) {
|
|
801
|
+
return (await readConfig(join2(root, ".envs/config.yml"))).history !== false;
|
|
802
|
+
}
|
|
292
803
|
function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
|
|
293
|
-
const entries =
|
|
804
|
+
const entries = dict();
|
|
294
805
|
for (const name of new Set([DEFAULT_PROFILE, profile])) {
|
|
295
806
|
const layer = values.profiles[name];
|
|
296
807
|
for (const [key, value] of Object.entries(layer.defaults)) {
|
|
@@ -299,14 +810,13 @@ function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
|
|
|
299
810
|
if (name === profile && !includeProfileEnvs)
|
|
300
811
|
continue;
|
|
301
812
|
for (const [key, byWorktree] of Object.entries(layer.envs)) {
|
|
302
|
-
if (byWorktree && worktree
|
|
813
|
+
if (byWorktree && Object.hasOwn(byWorktree, worktree)) {
|
|
303
814
|
entries[key] = String(byWorktree[worktree]);
|
|
304
815
|
}
|
|
305
816
|
}
|
|
306
817
|
}
|
|
307
818
|
return entries;
|
|
308
819
|
}
|
|
309
|
-
var hasCompleteProfiles = (raw) => Object.values(raw.profiles ?? {}).every((profile) => typeof profile?.defaults === "object" && profile.defaults !== null && typeof profile.envs === "object" && profile.envs !== null);
|
|
310
820
|
function stringifyValues(values) {
|
|
311
821
|
const profiles = Object.fromEntries(Object.entries(values.profiles).map(([name, { defaults, envs }]) => {
|
|
312
822
|
const kept = Object.fromEntries(Object.entries(envs).filter(([, byWorktree]) => Object.keys(byWorktree).length > 0));
|
|
@@ -315,8 +825,6 @@ function stringifyValues(values) {
|
|
|
315
825
|
const out = Object.keys(values.uses).length > 0 ? { uses: values.uses, profiles } : { profiles };
|
|
316
826
|
return SCHEMA_COMMENT + Bun.YAML.stringify(out, null, 2);
|
|
317
827
|
}
|
|
318
|
-
var SCHEMA_COMMENT = `# yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
|
|
319
|
-
`;
|
|
320
828
|
function checkProfiles(ctx, values, worktrees) {
|
|
321
829
|
let ok = true;
|
|
322
830
|
for (const { name } of worktrees) {
|
|
@@ -350,7 +858,7 @@ async function listWorktrees(cwd) {
|
|
|
350
858
|
});
|
|
351
859
|
}
|
|
352
860
|
function parseDotenv(text) {
|
|
353
|
-
const result =
|
|
861
|
+
const result = dict();
|
|
354
862
|
for (const raw of text.split(/\r?\n/)) {
|
|
355
863
|
const line = raw.trim();
|
|
356
864
|
if (!line || line.startsWith("#"))
|
|
@@ -386,27 +894,107 @@ function formatDotenvValue(value) {
|
|
|
386
894
|
return value;
|
|
387
895
|
return value.includes('"') ? `'${value}'` : `"${value}"`;
|
|
388
896
|
}
|
|
389
|
-
function updateDotenv(text, entries) {
|
|
897
|
+
function updateDotenv(text, entries, remove = new Set) {
|
|
390
898
|
const pending = new Map(Object.entries(entries));
|
|
391
899
|
const lines = text === "" ? [] : text.replace(/\n$/, "").split(`
|
|
392
900
|
`);
|
|
393
|
-
const updated =
|
|
901
|
+
const updated = [];
|
|
902
|
+
for (const line of lines) {
|
|
394
903
|
const key = line.trim().match(/^(?:export\s+)?([\w.-]+)\s*=/)?.[1];
|
|
395
|
-
if (key
|
|
396
|
-
|
|
904
|
+
if (key !== undefined && remove.has(key) && !pending.has(key))
|
|
905
|
+
continue;
|
|
906
|
+
if (key === undefined || !pending.has(key)) {
|
|
907
|
+
updated.push(line);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
397
910
|
const value = pending.get(key);
|
|
398
911
|
pending.delete(key);
|
|
399
|
-
|
|
400
|
-
}
|
|
912
|
+
updated.push(`${key}=${formatDotenvValue(value)}`);
|
|
913
|
+
}
|
|
401
914
|
for (const [key, value] of pending) {
|
|
402
915
|
updated.push(`${key}=${formatDotenvValue(value)}`);
|
|
403
916
|
}
|
|
404
|
-
return updated.join(`
|
|
917
|
+
return updated.length === 0 ? "" : updated.join(`
|
|
405
918
|
`) + `
|
|
406
919
|
`;
|
|
407
920
|
}
|
|
921
|
+
async function managedVariables(root, values) {
|
|
922
|
+
const names = new Set;
|
|
923
|
+
for (const profile of Object.values(values.profiles)) {
|
|
924
|
+
for (const key of Object.keys(profile.defaults))
|
|
925
|
+
names.add(key);
|
|
926
|
+
for (const key of Object.keys(profile.envs))
|
|
927
|
+
names.add(key);
|
|
928
|
+
}
|
|
929
|
+
const points = await readHistoryPoints(root);
|
|
930
|
+
for (const { changes } of points) {
|
|
931
|
+
for (const { path } of changes) {
|
|
932
|
+
if (path[0] === "profiles" && (path[2] === "defaults" || path[2] === "envs") && path[3] !== undefined) {
|
|
933
|
+
names.add(path[3]);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return names;
|
|
938
|
+
}
|
|
939
|
+
async function hardenPermissions(root) {
|
|
940
|
+
if (process.platform === "win32")
|
|
941
|
+
return;
|
|
942
|
+
const dir = join2(root, ".envs");
|
|
943
|
+
const tighten = async (path, mode) => {
|
|
944
|
+
const current = await stat(path).catch(() => {
|
|
945
|
+
return;
|
|
946
|
+
});
|
|
947
|
+
if (current && (current.mode & 511 & ~mode) !== 0)
|
|
948
|
+
await chmod(path, mode).catch(() => {});
|
|
949
|
+
};
|
|
950
|
+
await tighten(dir, 448);
|
|
951
|
+
for (const file of ["values.yml", "history.ndjson", "history.key", "config.yml"])
|
|
952
|
+
await tighten(join2(dir, file), 384);
|
|
953
|
+
}
|
|
954
|
+
async function withLock(root, fn) {
|
|
955
|
+
const held = heldLocks.getStore();
|
|
956
|
+
if (held?.has(root))
|
|
957
|
+
return await fn();
|
|
958
|
+
const dir = join2(root, ".envs");
|
|
959
|
+
if (!await Bun.file(join2(dir, ".gitignore")).exists() && !await Bun.file(join2(dir, "values.yml")).exists()) {
|
|
960
|
+
return await fn();
|
|
961
|
+
}
|
|
962
|
+
await hardenPermissions(root);
|
|
963
|
+
const path = join2(dir, "lock");
|
|
964
|
+
const deadline = Date.now() + 30000;
|
|
965
|
+
for (;; ) {
|
|
966
|
+
try {
|
|
967
|
+
const handle = await open(path, "wx", 384);
|
|
968
|
+
await handle.writeFile(`${process.pid} ${Date.now()}
|
|
969
|
+
`);
|
|
970
|
+
await handle.close();
|
|
971
|
+
break;
|
|
972
|
+
} catch (error) {
|
|
973
|
+
if (error.code !== "EEXIST")
|
|
974
|
+
throw error;
|
|
975
|
+
const text = await Bun.file(path).text().catch(() => "");
|
|
976
|
+
const pid = Number(text.split(" ")[0]);
|
|
977
|
+
const age = Date.now() - ((await stat(path).catch(() => {
|
|
978
|
+
return;
|
|
979
|
+
}))?.mtimeMs ?? Date.now());
|
|
980
|
+
if (Number.isInteger(pid) && pid > 0 && !processAlive(pid) || age > 120000) {
|
|
981
|
+
await unlink(path).catch(() => {});
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (Date.now() > deadline) {
|
|
985
|
+
throw new LockError(`.envs/lock is held by another envs command (pid ${Number.isInteger(pid) ? pid : "unknown"}). Wait for it, or remove .envs/lock if it is not running.`);
|
|
986
|
+
}
|
|
987
|
+
await Bun.sleep(15 + Math.random() * 35);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
return await heldLocks.run(new Set([...held ?? [], root]), fn);
|
|
992
|
+
} finally {
|
|
993
|
+
await unlink(path).catch(() => {});
|
|
994
|
+
}
|
|
995
|
+
}
|
|
408
996
|
async function readValues(ctx) {
|
|
409
|
-
const path =
|
|
997
|
+
const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
|
|
410
998
|
if (!await Bun.file(path).exists()) {
|
|
411
999
|
ctx.error(`${path} not found. Run \`envs init\` first.`);
|
|
412
1000
|
return;
|
|
@@ -418,9 +1006,9 @@ async function init(ctx) {
|
|
|
418
1006
|
const root = await findRoot(ctx.cwd);
|
|
419
1007
|
const files = { ".envs/.gitignore": `*
|
|
420
1008
|
`, ".envs/values.yml": SCHEMA_COMMENT };
|
|
421
|
-
await mkdir(
|
|
1009
|
+
await mkdir(join2(root, ".envs"), { recursive: true, mode: 448 });
|
|
422
1010
|
for (const [name, content] of Object.entries(files)) {
|
|
423
|
-
const path =
|
|
1011
|
+
const path = join2(root, name);
|
|
424
1012
|
if (await Bun.file(path).exists()) {
|
|
425
1013
|
ctx.log(`${path} already exists`);
|
|
426
1014
|
} else {
|
|
@@ -428,9 +1016,90 @@ async function init(ctx) {
|
|
|
428
1016
|
ctx.log(`Created ${path}`);
|
|
429
1017
|
}
|
|
430
1018
|
}
|
|
1019
|
+
await hardenPermissions(root);
|
|
431
1020
|
return 0;
|
|
432
1021
|
}
|
|
1022
|
+
async function readConfig(path) {
|
|
1023
|
+
if (!await Bun.file(path).exists())
|
|
1024
|
+
return {};
|
|
1025
|
+
return Bun.YAML.parse(await Bun.file(path).text()) ?? {};
|
|
1026
|
+
}
|
|
1027
|
+
async function writeConfig(root, path, config) {
|
|
1028
|
+
await mkdir(join2(root, ".envs"), { recursive: true, mode: 448 });
|
|
1029
|
+
const gitignore = join2(root, ".envs/.gitignore");
|
|
1030
|
+
if (!await Bun.file(gitignore).exists())
|
|
1031
|
+
await Bun.write(gitignore, `*
|
|
1032
|
+
`);
|
|
1033
|
+
const sorted = Object.fromEntries(Object.entries(config).sort(([a], [b]) => a.localeCompare(b)));
|
|
1034
|
+
await Bun.write(path, Object.keys(sorted).length ? Bun.YAML.stringify(sorted, null, 2) + `
|
|
1035
|
+
` : "", { mode: 384 });
|
|
1036
|
+
await chmod(path, 384);
|
|
1037
|
+
}
|
|
1038
|
+
async function config(ctx, args) {
|
|
1039
|
+
const [action, key, ...rest] = args;
|
|
1040
|
+
const root = await findRoot(ctx.cwd);
|
|
1041
|
+
const path = join2(root, ".envs/config.yml");
|
|
1042
|
+
const current = await readConfig(path);
|
|
1043
|
+
switch (action) {
|
|
1044
|
+
case "set": {
|
|
1045
|
+
if (!key || rest.length === 0) {
|
|
1046
|
+
ctx.error(`${CONFIG_USAGE}
|
|
1047
|
+
|
|
1048
|
+
Missing key or value for \`config set\`.`);
|
|
1049
|
+
return 1;
|
|
1050
|
+
}
|
|
1051
|
+
current[key] = parseValue(rest.join(" "));
|
|
1052
|
+
await writeConfig(root, path, current);
|
|
1053
|
+
ctx.log(`${key}=${current[key]}`);
|
|
1054
|
+
return 0;
|
|
1055
|
+
}
|
|
1056
|
+
case "get": {
|
|
1057
|
+
if (!key) {
|
|
1058
|
+
for (const [k, v] of Object.entries(current))
|
|
1059
|
+
ctx.log(`${k}=${v}`);
|
|
1060
|
+
return 0;
|
|
1061
|
+
}
|
|
1062
|
+
if (!(key in current)) {
|
|
1063
|
+
ctx.error(`Config key not found: ${key}`);
|
|
1064
|
+
return 1;
|
|
1065
|
+
}
|
|
1066
|
+
ctx.log(String(current[key]));
|
|
1067
|
+
return 0;
|
|
1068
|
+
}
|
|
1069
|
+
case "delete": {
|
|
1070
|
+
if (!key) {
|
|
1071
|
+
ctx.error(`${CONFIG_USAGE}
|
|
1072
|
+
|
|
1073
|
+
Missing key for \`config delete\`.`);
|
|
1074
|
+
return 1;
|
|
1075
|
+
}
|
|
1076
|
+
if (!(key in current)) {
|
|
1077
|
+
ctx.error(`Config key not found: ${key}`);
|
|
1078
|
+
return 1;
|
|
1079
|
+
}
|
|
1080
|
+
delete current[key];
|
|
1081
|
+
await writeConfig(root, path, current);
|
|
1082
|
+
ctx.log(`Deleted ${key}`);
|
|
1083
|
+
return 0;
|
|
1084
|
+
}
|
|
1085
|
+
default:
|
|
1086
|
+
ctx.error(CONFIG_USAGE);
|
|
1087
|
+
return 1;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
433
1090
|
async function push(ctx, only) {
|
|
1091
|
+
return await withLock(await findRoot(ctx.cwd), () => pushLocked(ctx, only));
|
|
1092
|
+
}
|
|
1093
|
+
async function pull(ctx) {
|
|
1094
|
+
return await withLock(await findRoot(ctx.cwd), () => pullLocked(ctx));
|
|
1095
|
+
}
|
|
1096
|
+
async function use(ctx, profile) {
|
|
1097
|
+
return await withLock(await findRoot(ctx.cwd), () => useLocked(ctx, profile));
|
|
1098
|
+
}
|
|
1099
|
+
async function restore(ctx, hash, options = {}) {
|
|
1100
|
+
return await withLock(await findRoot(ctx.cwd), () => restoreLocked(ctx, hash, options));
|
|
1101
|
+
}
|
|
1102
|
+
async function pushLocked(ctx, only) {
|
|
434
1103
|
const read = await readValues(ctx);
|
|
435
1104
|
if (!read)
|
|
436
1105
|
return 1;
|
|
@@ -438,52 +1107,83 @@ async function push(ctx, only) {
|
|
|
438
1107
|
const worktrees = (await listWorktrees(ctx.cwd)).filter(({ name }) => only === undefined || name === only);
|
|
439
1108
|
if (!checkProfiles(ctx, values, worktrees))
|
|
440
1109
|
return 1;
|
|
1110
|
+
let safe = true;
|
|
1111
|
+
for (const { name } of worktrees) {
|
|
1112
|
+
const profile = values.uses[name] ?? DEFAULT_PROFILE;
|
|
1113
|
+
for (const [key, value] of Object.entries(resolveEntries(values, name, profile))) {
|
|
1114
|
+
const problem = dotenvProblem(key, value);
|
|
1115
|
+
if (problem) {
|
|
1116
|
+
safe = false;
|
|
1117
|
+
ctx.error(`${JSON.stringify(key)} (${name}): ${problem}. Nothing was written.`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (!safe)
|
|
1122
|
+
return 1;
|
|
1123
|
+
const managed = await managedVariables(await findRoot(ctx.cwd), values);
|
|
441
1124
|
const changes = new Map;
|
|
1125
|
+
const removals = new Map;
|
|
442
1126
|
for (const { name, path } of worktrees) {
|
|
443
1127
|
const profile = values.uses[name] ?? DEFAULT_PROFILE;
|
|
444
1128
|
const entries = resolveEntries(values, name, profile);
|
|
445
|
-
if (Object.keys(entries).length === 0)
|
|
446
|
-
continue;
|
|
447
1129
|
const label = profile === DEFAULT_PROFILE ? name : `${name} [${profile}]`;
|
|
448
|
-
const dotenv = Bun.file(
|
|
1130
|
+
const dotenv = Bun.file(join2(path, ".env"));
|
|
449
1131
|
const current = await dotenv.exists() ? await dotenv.text() : "";
|
|
450
1132
|
const existing = parseDotenv(current);
|
|
1133
|
+
const orphans = new Set(Object.keys(existing).filter((key) => managed.has(key) && !(key in entries)));
|
|
1134
|
+
if (Object.keys(entries).length === 0 && orphans.size === 0)
|
|
1135
|
+
continue;
|
|
451
1136
|
for (const [key, value] of Object.entries(entries)) {
|
|
452
1137
|
if (existing[key] === value)
|
|
453
1138
|
continue;
|
|
454
|
-
const shown =
|
|
1139
|
+
const shown = displayValue(key, value);
|
|
455
1140
|
const line = `${key}=${shown}`;
|
|
456
1141
|
changes.set(line, [...changes.get(line) ?? [], label]);
|
|
457
1142
|
}
|
|
458
|
-
|
|
1143
|
+
for (const key of orphans)
|
|
1144
|
+
removals.set(key, [...removals.get(key) ?? [], label]);
|
|
1145
|
+
await writeFile(join2(path, ".env"), updateDotenv(current, entries, orphans), { mode: 384 });
|
|
459
1146
|
}
|
|
460
1147
|
for (const [line, names] of changes) {
|
|
461
1148
|
ctx.log(yellow(`\u21BB ${line} \u2192 ${names.join(", ")}`));
|
|
462
1149
|
}
|
|
1150
|
+
for (const [key, names] of removals) {
|
|
1151
|
+
ctx.log(red(`\u2715 ${key} removed from ${names.join(", ")}`));
|
|
1152
|
+
}
|
|
463
1153
|
return 0;
|
|
464
1154
|
}
|
|
465
|
-
async function
|
|
1155
|
+
async function pullLocked(ctx) {
|
|
466
1156
|
const read = await readValues(ctx);
|
|
467
1157
|
if (!read)
|
|
468
1158
|
return 1;
|
|
469
1159
|
const values = normalizeValues(read.values);
|
|
1160
|
+
const before = cloneState(values);
|
|
470
1161
|
const worktrees = await listWorktrees(ctx.cwd);
|
|
471
1162
|
materializeProfiles(values);
|
|
1163
|
+
const missingDefaults = new Map;
|
|
472
1164
|
const pulledNames = [];
|
|
473
1165
|
const pulledKeys = new Set;
|
|
474
1166
|
for (const { name, path } of worktrees) {
|
|
475
1167
|
const profile = values.uses[name] ??= DEFAULT_PROFILE;
|
|
476
|
-
const dotenv = Bun.file(
|
|
1168
|
+
const dotenv = Bun.file(join2(path, ".env"));
|
|
477
1169
|
if (!await dotenv.exists())
|
|
478
1170
|
continue;
|
|
479
1171
|
const parsed = parseDotenv(await dotenv.text());
|
|
480
1172
|
pulledNames.push(name);
|
|
481
1173
|
for (const key of Object.keys(parsed))
|
|
482
1174
|
pulledKeys.add(key);
|
|
1175
|
+
for (const layer of new Set([DEFAULT_PROFILE, profile])) {
|
|
1176
|
+
for (const key of Object.keys(values.profiles[layer].defaults)) {
|
|
1177
|
+
if (!(key in parsed))
|
|
1178
|
+
missingDefaults.set(key, [...missingDefaults.get(key) ?? [], name]);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
483
1181
|
const { envs } = values.profiles[profile];
|
|
484
|
-
for (const [
|
|
485
|
-
|
|
486
|
-
|
|
1182
|
+
for (const layer of new Set([DEFAULT_PROFILE, profile])) {
|
|
1183
|
+
for (const [key, byWorktree] of Object.entries(values.profiles[layer].envs)) {
|
|
1184
|
+
if (!(key in parsed))
|
|
1185
|
+
delete byWorktree[name];
|
|
1186
|
+
}
|
|
487
1187
|
}
|
|
488
1188
|
const base = resolveEntries(values, name, profile, false);
|
|
489
1189
|
for (const [key, value] of Object.entries(parsed)) {
|
|
@@ -492,17 +1192,22 @@ async function pull(ctx) {
|
|
|
492
1192
|
if (envs[key])
|
|
493
1193
|
delete envs[key][name];
|
|
494
1194
|
} else {
|
|
495
|
-
(envs[key] ??=
|
|
1195
|
+
(envs[key] ??= dict())[name] = parsedValue;
|
|
496
1196
|
}
|
|
497
1197
|
}
|
|
498
1198
|
}
|
|
499
1199
|
await Bun.write(read.path, stringifyValues(values));
|
|
1200
|
+
const root = await findRoot(ctx.cwd);
|
|
1201
|
+
await recordHistory(root, before, values, ctx.now, ctx.error);
|
|
1202
|
+
for (const [key, names] of missingDefaults) {
|
|
1203
|
+
ctx.log(yellow(`! ${key} is in the defaults but missing from the .env of ${names.join(", ")}: \`envs push\` writes it back. Remove it from the defaults to drop it from every worktree.`));
|
|
1204
|
+
}
|
|
500
1205
|
if (pulledNames.length > 0) {
|
|
501
1206
|
ctx.log(green(`\u2193 pulling ${pulledNames.join(", ")} - ${pulledKeys.size} variables`));
|
|
502
1207
|
}
|
|
503
1208
|
return 0;
|
|
504
1209
|
}
|
|
505
|
-
async function
|
|
1210
|
+
async function useLocked(ctx, profile) {
|
|
506
1211
|
const read = await readValues(ctx);
|
|
507
1212
|
if (!read)
|
|
508
1213
|
return 1;
|
|
@@ -524,12 +1229,103 @@ async function use(ctx, profile) {
|
|
|
524
1229
|
ctx.error("Run `envs use` inside a git worktree.");
|
|
525
1230
|
return 1;
|
|
526
1231
|
}
|
|
1232
|
+
const before = cloneState(values);
|
|
527
1233
|
for (const { name } of worktrees)
|
|
528
1234
|
values.uses[name] ??= DEFAULT_PROFILE;
|
|
529
1235
|
values.uses[current.name] = profile;
|
|
530
1236
|
await Bun.write(read.path, stringifyValues(values));
|
|
1237
|
+
await recordHistory(await findRoot(ctx.cwd), before, values, ctx.now, ctx.error);
|
|
531
1238
|
return await push(ctx, current.name);
|
|
532
1239
|
}
|
|
1240
|
+
async function scanHistory(root) {
|
|
1241
|
+
const file = Bun.file(join2(root, ".envs/history.ndjson"));
|
|
1242
|
+
const scan = { points: [], damaged: [], torn: false, complete: "", endsWithNewline: true, broken: [] };
|
|
1243
|
+
if (!await file.exists())
|
|
1244
|
+
return scan;
|
|
1245
|
+
const text = await file.text();
|
|
1246
|
+
scan.endsWithNewline = text === "" || text.endsWith(`
|
|
1247
|
+
`);
|
|
1248
|
+
scan.complete = text.slice(0, text.lastIndexOf(`
|
|
1249
|
+
`) + 1);
|
|
1250
|
+
const lines = text.split(`
|
|
1251
|
+
`);
|
|
1252
|
+
lines.forEach((line, i) => {
|
|
1253
|
+
if (line.trim() === "")
|
|
1254
|
+
return;
|
|
1255
|
+
let value;
|
|
1256
|
+
try {
|
|
1257
|
+
value = JSON.parse(line);
|
|
1258
|
+
} catch {
|
|
1259
|
+
value = undefined;
|
|
1260
|
+
}
|
|
1261
|
+
if (isHistoryPoint(value))
|
|
1262
|
+
scan.points.push(value);
|
|
1263
|
+
else if (i === lines.length - 1 && !scan.endsWithNewline)
|
|
1264
|
+
scan.torn = true;
|
|
1265
|
+
else
|
|
1266
|
+
scan.damaged.push(i + 1);
|
|
1267
|
+
});
|
|
1268
|
+
scan.broken = verifyChain(scan.points);
|
|
1269
|
+
return scan;
|
|
1270
|
+
}
|
|
1271
|
+
function verifyChain(points) {
|
|
1272
|
+
const broken = [];
|
|
1273
|
+
let previous = null;
|
|
1274
|
+
for (const point of points) {
|
|
1275
|
+
if (hashOf(point) !== point.hash)
|
|
1276
|
+
broken.push({ hash: point.hash, reason: "content" });
|
|
1277
|
+
else if (point.prev !== previous)
|
|
1278
|
+
broken.push({ hash: point.hash, reason: "prev" });
|
|
1279
|
+
previous = point.hash;
|
|
1280
|
+
}
|
|
1281
|
+
return broken;
|
|
1282
|
+
}
|
|
1283
|
+
async function readHistoryPoints(root) {
|
|
1284
|
+
return (await scanHistory(root)).points;
|
|
1285
|
+
}
|
|
1286
|
+
async function restoreLocked(ctx, hash, options = {}) {
|
|
1287
|
+
if (!hash) {
|
|
1288
|
+
ctx.error("Usage: envs restore <hash> [--force]");
|
|
1289
|
+
return 1;
|
|
1290
|
+
}
|
|
1291
|
+
const read = await readValues(ctx);
|
|
1292
|
+
if (!read)
|
|
1293
|
+
return 1;
|
|
1294
|
+
const root = await findRoot(ctx.cwd);
|
|
1295
|
+
if (!await historyEnabled(root)) {
|
|
1296
|
+
ctx.error("The history is off, so a restore would not be recorded. Run `envs config set history true` first.");
|
|
1297
|
+
return 1;
|
|
1298
|
+
}
|
|
1299
|
+
const scan = await scanHistory(root);
|
|
1300
|
+
if (scan.damaged.length > 0) {
|
|
1301
|
+
ctx.error(`.envs/history.ndjson has damaged lines (${scan.damaged.join(", ")}), so the state of a point cannot be rebuilt exactly. Repair or remove those lines and try again.`);
|
|
1302
|
+
return 1;
|
|
1303
|
+
}
|
|
1304
|
+
if (scan.broken.length > 0) {
|
|
1305
|
+
const message = `.envs/history.ndjson: the hash chain is broken at ${describeBroken(scan.broken)}. The history may have been altered, so the state of a point cannot be trusted.`;
|
|
1306
|
+
if (!options.force) {
|
|
1307
|
+
ctx.error(`${message} Check the file, or run \`envs restore ${hash} --force\` to restore anyway.`);
|
|
1308
|
+
return 1;
|
|
1309
|
+
}
|
|
1310
|
+
ctx.error(`${message} Restoring anyway (--force).`);
|
|
1311
|
+
}
|
|
1312
|
+
const points = scan.points;
|
|
1313
|
+
const found = findHistoryPoint(points, hash);
|
|
1314
|
+
if ("error" in found) {
|
|
1315
|
+
ctx.error(found.error === "none" ? `No history point matches "${hash}". Run \`envs restore\` with a hash from .envs/history.ndjson.` : `"${hash}" matches ${found.matches} history points; use more characters.`);
|
|
1316
|
+
return 1;
|
|
1317
|
+
}
|
|
1318
|
+
const target = found.point;
|
|
1319
|
+
const restored = stateAfter(points, target);
|
|
1320
|
+
const current = normalizeValues(read.values);
|
|
1321
|
+
const notes = unredact(restored, current, (await redactor(root)).mark);
|
|
1322
|
+
await Bun.write(read.path, stringifyValues(restored));
|
|
1323
|
+
for (const note of notes)
|
|
1324
|
+
ctx.log(yellow(`! ${note}`));
|
|
1325
|
+
const added = await recordHistory(root, current, restored, ctx.now, ctx.error);
|
|
1326
|
+
ctx.log(added ? green(`\u21BA restored ${target.hash} - new history point ${added}`) : `Already at ${target.hash}: nothing to restore`);
|
|
1327
|
+
return await push(ctx);
|
|
1328
|
+
}
|
|
533
1329
|
async function isIgnored(dir, file) {
|
|
534
1330
|
const result = await $`git check-ignore -q ${file}`.cwd(dir).nothrow().quiet();
|
|
535
1331
|
return result.exitCode === 0;
|
|
@@ -547,15 +1343,20 @@ async function lint(ctx) {
|
|
|
547
1343
|
const modeOf = async (path) => process.platform === "win32" ? undefined : (await stat(path)).mode;
|
|
548
1344
|
const dotenvs = [];
|
|
549
1345
|
for (const worktree of worktrees) {
|
|
550
|
-
const path =
|
|
1346
|
+
const path = join2(worktree.path, ".env");
|
|
551
1347
|
if (await Bun.file(path).exists()) {
|
|
552
1348
|
dotenvs.push({ ...worktree, mode: await modeOf(path) });
|
|
553
1349
|
}
|
|
554
1350
|
}
|
|
1351
|
+
const historyPath = join2(root, ".envs/history.ndjson");
|
|
1352
|
+
const hasHistory = await Bun.file(historyPath).exists();
|
|
555
1353
|
const lintContext = {
|
|
556
1354
|
values: read.values,
|
|
557
1355
|
root,
|
|
558
1356
|
valuesMode: await modeOf(read.path),
|
|
1357
|
+
hasHistory,
|
|
1358
|
+
historyMode: hasHistory ? await modeOf(historyPath) : undefined,
|
|
1359
|
+
worktrees: worktrees.map(({ name }) => name),
|
|
559
1360
|
dotenvs,
|
|
560
1361
|
isTracked,
|
|
561
1362
|
isIgnored
|
|
@@ -575,39 +1376,128 @@ async function lint(ctx) {
|
|
|
575
1376
|
${count} warning(s) found.`);
|
|
576
1377
|
return 1;
|
|
577
1378
|
}
|
|
578
|
-
async function edit(ctx) {
|
|
579
|
-
const valuesPath =
|
|
1379
|
+
async function edit(ctx, options = {}) {
|
|
1380
|
+
const valuesPath = join2(await findRoot(ctx.cwd), ".envs/values.yml");
|
|
580
1381
|
if (!await Bun.file(valuesPath).exists())
|
|
581
1382
|
await init(ctx);
|
|
582
1383
|
const pulled = await pull(ctx);
|
|
583
1384
|
if (pulled !== 0)
|
|
584
1385
|
return pulled;
|
|
1386
|
+
if (options.ui) {
|
|
1387
|
+
await Promise.resolve().then(() => init_server());
|
|
1388
|
+
return await serveUi(ctx);
|
|
1389
|
+
}
|
|
585
1390
|
const original = await Bun.file(valuesPath).text();
|
|
1391
|
+
const before = normalizeValues(Bun.YAML.parse(original) ?? {});
|
|
586
1392
|
await Bun.write(valuesPath, EDIT_NOTICE + original);
|
|
587
1393
|
const code = await ctx.openEditor(valuesPath);
|
|
588
1394
|
const edited = await Bun.file(valuesPath).text();
|
|
1395
|
+
if (code !== 0) {
|
|
1396
|
+
await Bun.write(valuesPath, original);
|
|
1397
|
+
return code;
|
|
1398
|
+
}
|
|
589
1399
|
if (edited.startsWith(EDIT_NOTICE)) {
|
|
590
1400
|
await Bun.write(valuesPath, edited.slice(EDIT_NOTICE.length));
|
|
591
1401
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1402
|
+
return await withLock(await findRoot(ctx.cwd), async () => {
|
|
1403
|
+
const raw = Bun.YAML.parse(await Bun.file(valuesPath).text()) ?? {};
|
|
1404
|
+
const values = normalizeValues(raw);
|
|
1405
|
+
const created = materializeProfiles(values);
|
|
1406
|
+
if (created.length > 0 || !hasCompleteProfiles(raw)) {
|
|
1407
|
+
await Bun.write(valuesPath, stringifyValues(values));
|
|
1408
|
+
}
|
|
1409
|
+
if (created.length > 0) {
|
|
1410
|
+
ctx.log(green(`+ created ${created.join(", ")} from ${DEFAULT_PROFILE}`));
|
|
1411
|
+
}
|
|
1412
|
+
await recordHistory(await findRoot(ctx.cwd), before, values, ctx.now, ctx.error);
|
|
1413
|
+
return await push(ctx);
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
async function loadUiState(ctx) {
|
|
1417
|
+
const read = await readValues(ctx);
|
|
1418
|
+
if (!read)
|
|
1419
|
+
throw new Error("Run `envs init` first.");
|
|
1420
|
+
const [main, ...others] = (await listWorktrees(ctx.cwd)).map(({ name }) => name);
|
|
1421
|
+
others.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
1422
|
+
const worktrees = main === undefined ? [] : [main, ...others];
|
|
1423
|
+
return { ...normalizeValues(read.values), worktrees, version: valuesVersion(await Bun.file(read.path).text()) };
|
|
1424
|
+
}
|
|
1425
|
+
async function loadUiHistory(ctx) {
|
|
1426
|
+
const root = await findRoot(ctx.cwd);
|
|
1427
|
+
const enabled = await historyEnabled(root);
|
|
1428
|
+
if (!enabled)
|
|
1429
|
+
return { enabled, points: [] };
|
|
1430
|
+
const { points, damaged, broken } = await scanHistory(root);
|
|
1431
|
+
return {
|
|
1432
|
+
enabled,
|
|
1433
|
+
points,
|
|
1434
|
+
...damaged.length > 0 && { damaged },
|
|
1435
|
+
...broken.length > 0 && { broken: broken.map(({ hash }) => hash) }
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
async function saveUiState(ctx, input, options = {}) {
|
|
1439
|
+
return await withLock(await findRoot(ctx.cwd), () => saveUiStateLocked(ctx, input, options));
|
|
1440
|
+
}
|
|
1441
|
+
async function saveUiStateLocked(ctx, input, options = {}) {
|
|
1442
|
+
const root = await findRoot(ctx.cwd);
|
|
1443
|
+
const path = join2(root, ".envs/values.yml");
|
|
1444
|
+
const currentText = await Bun.file(path).exists() ? await Bun.file(path).text() : "";
|
|
1445
|
+
if (options.base !== undefined && options.base !== valuesVersion(currentText)) {
|
|
1446
|
+
ctx.error("values.yml changed after the editor was loaded (another command or editor saved it). Reload the editor and apply your changes again; nothing was saved.");
|
|
1447
|
+
return { created: [], code: 1, conflict: true };
|
|
599
1448
|
}
|
|
600
|
-
|
|
601
|
-
|
|
1449
|
+
const before = normalizeValues(Bun.YAML.parse(currentText) ?? {});
|
|
1450
|
+
const values = normalizeValues({ uses: input.uses, profiles: input.profiles });
|
|
1451
|
+
const typed = (value, previous) => typeof value !== "string" ? value : previous !== undefined && String(previous) === value ? previous : parseValue(value);
|
|
1452
|
+
for (const [name, profile] of Object.entries(values.profiles)) {
|
|
1453
|
+
const old = Object.hasOwn(before.profiles, name) ? before.profiles[name] : undefined;
|
|
1454
|
+
for (const [key, value] of Object.entries(profile.defaults)) {
|
|
1455
|
+
profile.defaults[key] = typed(value, old && Object.hasOwn(old.defaults, key) ? old.defaults[key] : undefined);
|
|
1456
|
+
}
|
|
1457
|
+
for (const [key, byWorktree] of Object.entries(profile.envs)) {
|
|
1458
|
+
const oldByWorktree = old && Object.hasOwn(old.envs, key) ? old.envs[key] : undefined;
|
|
1459
|
+
for (const [worktree, value] of Object.entries(byWorktree)) {
|
|
1460
|
+
byWorktree[worktree] = typed(value, oldByWorktree && Object.hasOwn(oldByWorktree, worktree) ? oldByWorktree[worktree] : undefined);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
602
1463
|
}
|
|
603
|
-
|
|
1464
|
+
let safe = true;
|
|
1465
|
+
for (const [profileName, profile] of Object.entries(values.profiles)) {
|
|
1466
|
+
const check = (key, value, where) => {
|
|
1467
|
+
const problem = dotenvProblem(key, String(value));
|
|
1468
|
+
if (!problem)
|
|
1469
|
+
return;
|
|
1470
|
+
safe = false;
|
|
1471
|
+
ctx.error(`${JSON.stringify(key)} (${where}): ${problem}. Nothing was saved.`);
|
|
1472
|
+
};
|
|
1473
|
+
for (const [key, value] of Object.entries(profile.defaults))
|
|
1474
|
+
check(key, value, `${profileName} defaults`);
|
|
1475
|
+
for (const [key, byWorktree] of Object.entries(profile.envs)) {
|
|
1476
|
+
for (const [worktree, value] of Object.entries(byWorktree))
|
|
1477
|
+
check(key, value, `${profileName} ${worktree}`);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (!safe)
|
|
1481
|
+
return { created: [], code: 1 };
|
|
1482
|
+
const created = materializeProfiles(values);
|
|
1483
|
+
await Bun.write(path, stringifyValues(values));
|
|
1484
|
+
await recordHistory(root, before, values, ctx.now, ctx.error);
|
|
1485
|
+
return { created, code: await push(ctx) };
|
|
1486
|
+
}
|
|
1487
|
+
function valuesVersion(text) {
|
|
1488
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
604
1489
|
}
|
|
605
|
-
var EDIT_NOTICE = `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
606
|
-
# Close this file when your changes are ready, then they are pushed.
|
|
607
|
-
# To cancel the edit, press Ctrl+C in the terminal and then close this file.
|
|
608
|
-
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
609
|
-
`;
|
|
610
1490
|
async function run(args, ctx = defaultContext()) {
|
|
1491
|
+
try {
|
|
1492
|
+
return await dispatch(args, ctx);
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
if (!(error instanceof LockError))
|
|
1495
|
+
throw error;
|
|
1496
|
+
ctx.error(error.message);
|
|
1497
|
+
return 1;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
async function dispatch(args, ctx) {
|
|
611
1501
|
const [command = "help"] = args;
|
|
612
1502
|
switch (command) {
|
|
613
1503
|
case "help":
|
|
@@ -624,9 +1514,15 @@ async function run(args, ctx = defaultContext()) {
|
|
|
624
1514
|
case "lint":
|
|
625
1515
|
return await lint(ctx);
|
|
626
1516
|
case "edit":
|
|
627
|
-
return await edit(ctx);
|
|
1517
|
+
return await edit(ctx, { ui: args.includes("--ui") });
|
|
628
1518
|
case "use":
|
|
629
1519
|
return await use(ctx, args[1]);
|
|
1520
|
+
case "history":
|
|
1521
|
+
return await historyCommand(ctx, args.slice(1));
|
|
1522
|
+
case "restore":
|
|
1523
|
+
return await restore(ctx, args.slice(1).find((arg) => !arg.startsWith("--")), { force: args.includes("--force") });
|
|
1524
|
+
case "config":
|
|
1525
|
+
return await config(ctx, args.slice(1));
|
|
630
1526
|
default:
|
|
631
1527
|
ctx.error(`Unknown command: ${command}
|
|
632
1528
|
|
|
@@ -634,6 +1530,68 @@ ${HELP}`);
|
|
|
634
1530
|
return 1;
|
|
635
1531
|
}
|
|
636
1532
|
}
|
|
1533
|
+
var HELP = `Usage: envs <command>
|
|
1534
|
+
|
|
1535
|
+
Commands:
|
|
1536
|
+
init Create the .envs/ directory (.gitignore, values.yml)
|
|
1537
|
+
pull Read each worktree's .env and write it into .envs/values.yml
|
|
1538
|
+
push Write defaults and .envs/values.yml into the .env of each worktree
|
|
1539
|
+
lint Warn about insecure values and unprotected secret files
|
|
1540
|
+
edit Pull, open .envs/values.yml in VS Code (code -w), then push
|
|
1541
|
+
With --ui, serve a web editor instead; its Save button pushes
|
|
1542
|
+
use Select the profile of the current worktree (envs use <profile>), or list them
|
|
1543
|
+
restore Restore values.yml to the state after a history point (envs restore <hash> [--force]), adding a new point
|
|
1544
|
+
history Manage the history (history redact: remove secrets in clear from an existing history)
|
|
1545
|
+
config Read or change .envs/config.yml (config set <key> <value>, config get [key], config delete <key>)
|
|
1546
|
+
help Show this help message
|
|
1547
|
+
`, colorsDisabled = () => Boolean(process.env.NO_COLOR), MASK = "********", isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key), URL_SECRET, hasUrlSecret = (value) => URL_SECRET.test(value) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value) && sensitiveQuery(value), sensitiveQuery = (value) => [...value.matchAll(/[?&]([^=&\s#]+)=([^&\s#]*)/g)].some(([, name]) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_"))), maskUrlSecrets = (value) => value.replace(URL_SECRET, `$1${MASK}$3`).replace(/([?&])([^=&\s#]+)=([^&\s#]*)/g, (all, sep, name) => isMaskedKey(name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")) ? `${sep}${name}=${MASK}` : all), isSecretEntry = (name, value) => isMaskedKey(name) || hasUrlSecret(value), displayValue = (name, value) => isMaskedKey(name) ? MASK : formatDotenvValue(maskUrlSecrets(value)), paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`, green = (text) => paint(32, text), yellow = (text) => paint(33, text), red = (text) => paint(31, text), defaultContext = () => ({
|
|
1548
|
+
cwd: process.cwd(),
|
|
1549
|
+
log: (message) => console.log(message),
|
|
1550
|
+
error: (message) => console.error(message),
|
|
1551
|
+
openEditor: async (path) => {
|
|
1552
|
+
const proc = Bun.spawn(["code", "-w", path], {
|
|
1553
|
+
env: { ...process.env },
|
|
1554
|
+
stdio: ["inherit", "inherit", "inherit"]
|
|
1555
|
+
});
|
|
1556
|
+
return await proc.exited;
|
|
1557
|
+
},
|
|
1558
|
+
openUrl: async (url) => {
|
|
1559
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
1560
|
+
try {
|
|
1561
|
+
await Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] }).exited;
|
|
1562
|
+
} catch {}
|
|
1563
|
+
},
|
|
1564
|
+
waitForExit: () => new Promise((resolve) => {
|
|
1565
|
+
process.once("SIGINT", () => resolve());
|
|
1566
|
+
process.once("SIGTERM", () => resolve());
|
|
1567
|
+
})
|
|
1568
|
+
}), hasProfile = (values, name) => Object.hasOwn(values.profiles, name), hasCompleteProfiles = (raw) => Object.values(raw.profiles ?? {}).every((profile) => typeof profile?.defaults === "object" && profile.defaults !== null && typeof profile.envs === "object" && profile.envs !== null), SCHEMA_COMMENT = `# yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
|
|
1569
|
+
`, LockError, heldLocks, processAlive = (pid) => {
|
|
1570
|
+
try {
|
|
1571
|
+
process.kill(pid, 0);
|
|
1572
|
+
return true;
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
return error.code === "EPERM";
|
|
1575
|
+
}
|
|
1576
|
+
}, CONFIG_USAGE = `Usage: envs config <set <key> <value> | get [key] | delete <key>>`, isHistoryPoint = (value) => {
|
|
1577
|
+
const point = value;
|
|
1578
|
+
return typeof point === "object" && point !== null && typeof point.hash === "string" && (typeof point.prev === "string" || point.prev === null) && typeof point.timestamp === "string" && Array.isArray(point.changes);
|
|
1579
|
+
}, hashOf = (point) => {
|
|
1580
|
+
const { hash: _, ...rest } = point;
|
|
1581
|
+
return createHash("sha256").update(JSON.stringify(rest)).digest("hex").slice(0, 12);
|
|
1582
|
+
}, describeBroken = (broken) => broken.map(({ hash, reason }) => `${hash} (${reason === "content" ? "its content does not match its hash" : "it does not follow the previous point"})`).join(", "), EDIT_NOTICE = `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1583
|
+
# Close this file when your changes are ready, then they are pushed.
|
|
1584
|
+
# To cancel the edit, press Ctrl+C in the terminal and then close this file.
|
|
1585
|
+
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1586
|
+
`;
|
|
1587
|
+
var init_envs = __esm(() => {
|
|
1588
|
+
init_lint_rules();
|
|
1589
|
+
URL_SECRET = /^([a-z][a-z0-9+.-]*:\/\/[^\/\s:@]*:)([^@\/\s]+)(@)/i;
|
|
1590
|
+
LockError = class LockError extends Error {
|
|
1591
|
+
};
|
|
1592
|
+
heldLocks = new AsyncLocalStorage;
|
|
1593
|
+
});
|
|
637
1594
|
|
|
638
1595
|
// src/bin/envs.ts
|
|
1596
|
+
init_envs();
|
|
639
1597
|
process.exit(await run(Bun.argv.slice(2)));
|