@jondotsoy/envs 0.1.6 → 0.1.8
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 +43 -15
- package/envs.js +475 -245
- package/package.json +1 -1
- package/ui/chunk-293xt424.js +239 -0
- package/ui/chunk-nqsgf5a0.css +1 -0
- package/ui/index.html +14 -0
package/envs.js
CHANGED
|
@@ -1,57 +1,41 @@
|
|
|
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
|
-
var SENSITIVE_KEY = /(^|_)(SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIALS?|AUTH|SALT|SIGNING)(_|$)/i;
|
|
12
|
-
var WEAK_VALUES = new Set([
|
|
13
|
-
"",
|
|
14
|
-
"changeme",
|
|
15
|
-
"change-me",
|
|
16
|
-
"change_me",
|
|
17
|
-
"password",
|
|
18
|
-
"passw0rd",
|
|
19
|
-
"secret",
|
|
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
|
-
];
|
|
38
16
|
function isSensitiveKey(key) {
|
|
39
17
|
return SENSITIVE_KEY.test(key);
|
|
40
18
|
}
|
|
41
19
|
function entries({ values }) {
|
|
42
20
|
const result = [];
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
for (const [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
21
|
+
const collect = (prefix, section) => {
|
|
22
|
+
for (const [key, value] of Object.entries(section?.defaults ?? {})) {
|
|
23
|
+
result.push({ key, text: String(value), location: `${prefix}defaults.${key}`, shared: true });
|
|
24
|
+
}
|
|
25
|
+
for (const [key, byWorktree] of Object.entries(section?.envs ?? {})) {
|
|
26
|
+
for (const [worktree, value] of Object.entries(byWorktree ?? {})) {
|
|
27
|
+
result.push({
|
|
28
|
+
key,
|
|
29
|
+
text: String(value),
|
|
30
|
+
location: `${prefix}envs.${key}.${worktree}`,
|
|
31
|
+
shared: false
|
|
32
|
+
});
|
|
33
|
+
}
|
|
54
34
|
}
|
|
35
|
+
};
|
|
36
|
+
collect("", values);
|
|
37
|
+
for (const [name, profile] of Object.entries(values.profiles ?? {})) {
|
|
38
|
+
collect(`profiles.${name}.`, profile);
|
|
55
39
|
}
|
|
56
40
|
return result;
|
|
57
41
|
}
|
|
@@ -68,185 +52,339 @@ function parseUrl(text) {
|
|
|
68
52
|
local: /^(localhost|127\.\d+\.\d+\.\d+|::1|0\.0\.0\.0)$/.test(hostname)
|
|
69
53
|
};
|
|
70
54
|
}
|
|
71
|
-
var valuesFile = ".envs/values.yml";
|
|
72
55
|
function envFiles(ctx) {
|
|
73
56
|
return [
|
|
74
57
|
{ location: valuesFile, mode: ctx.valuesMode },
|
|
75
58
|
...ctx.dotenvs.map((d) => ({ location: `${d.name}/.env`, mode: d.mode }))
|
|
76
59
|
];
|
|
77
60
|
}
|
|
78
|
-
var
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
61
|
+
var SENSITIVE_KEY, WEAK_VALUES, SECRET_PATTERNS, valuesFile = ".envs/values.yml", rules;
|
|
62
|
+
var init_lint_rules = __esm(() => {
|
|
63
|
+
SENSITIVE_KEY = /(^|_)(SECRET|PASSWORD|PASSWD|PWD|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY|CREDENTIALS?|AUTH|SALT|SIGNING)(_|$)/i;
|
|
64
|
+
WEAK_VALUES = new Set([
|
|
65
|
+
"",
|
|
66
|
+
"changeme",
|
|
67
|
+
"change-me",
|
|
68
|
+
"change_me",
|
|
69
|
+
"password",
|
|
70
|
+
"passw0rd",
|
|
71
|
+
"secret",
|
|
72
|
+
"admin",
|
|
73
|
+
"root",
|
|
74
|
+
"test",
|
|
75
|
+
"default",
|
|
76
|
+
"example",
|
|
77
|
+
"123456",
|
|
78
|
+
"12345678",
|
|
79
|
+
"qwerty",
|
|
80
|
+
"todo",
|
|
81
|
+
"xxx"
|
|
82
|
+
]);
|
|
83
|
+
SECRET_PATTERNS = [
|
|
84
|
+
[/AKIA[0-9A-Z]{16}/, "an AWS access key id"],
|
|
85
|
+
[/gh[pousr]_[A-Za-z0-9]{36,}/, "a GitHub token"],
|
|
86
|
+
[/sk-[A-Za-z0-9_-]{20,}/, "an API secret key"],
|
|
87
|
+
[/xox[abprs]-[A-Za-z0-9-]{10,}/, "a Slack token"],
|
|
88
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"]
|
|
89
|
+
];
|
|
90
|
+
rules = [
|
|
91
|
+
{
|
|
92
|
+
id: "tracked-values",
|
|
93
|
+
description: "values.yml is tracked by git.",
|
|
94
|
+
async check(ctx) {
|
|
95
|
+
if (!await ctx.isTracked(ctx.root, valuesFile))
|
|
96
|
+
return [];
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
location: valuesFile,
|
|
100
|
+
message: "values.yml is tracked by git, so its secrets are in the history. Run `git rm --cached .envs/values.yml`."
|
|
101
|
+
}
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "unignored-values",
|
|
107
|
+
description: "values.yml is neither tracked nor ignored by git.",
|
|
108
|
+
async check(ctx) {
|
|
109
|
+
if (await ctx.isTracked(ctx.root, valuesFile))
|
|
110
|
+
return [];
|
|
111
|
+
if (await ctx.isIgnored(ctx.root, valuesFile))
|
|
112
|
+
return [];
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
location: valuesFile,
|
|
116
|
+
message: "values.yml is not ignored by git and could be committed by accident. Add `*` to .envs/.gitignore."
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "open-permissions",
|
|
123
|
+
description: "values.yml is readable by other users.",
|
|
124
|
+
check({ valuesMode }) {
|
|
125
|
+
if (valuesMode === undefined || (valuesMode & 63) === 0)
|
|
126
|
+
return [];
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
location: valuesFile,
|
|
130
|
+
message: `values.yml is readable by other users (mode ${(valuesMode & 511).toString(8)}). Run \`chmod 600 .envs/values.yml\`.`
|
|
131
|
+
}
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "executable-files",
|
|
137
|
+
description: "values.yml or a worktree's .env has execute permission.",
|
|
138
|
+
check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 73) !== 0).map((f) => ({
|
|
139
|
+
location: f.location,
|
|
140
|
+
message: `The file is executable (mode ${(f.mode & 511).toString(8)}); env files are data and must not be. Run \`chmod -x\` on it.`
|
|
141
|
+
}))
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "writable-files",
|
|
145
|
+
description: "values.yml or a worktree's .env is writable by other users.",
|
|
146
|
+
check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 18) !== 0).map((f) => ({
|
|
147
|
+
location: f.location,
|
|
148
|
+
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.`
|
|
149
|
+
}))
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "tracked-dotenv",
|
|
153
|
+
description: "A worktree's .env is tracked by git.",
|
|
154
|
+
async check(ctx) {
|
|
155
|
+
const found = [];
|
|
156
|
+
for (const { name, path } of ctx.dotenvs) {
|
|
157
|
+
if (!await ctx.isTracked(path, ".env"))
|
|
158
|
+
continue;
|
|
159
|
+
found.push({
|
|
160
|
+
location: `${name}/.env`,
|
|
161
|
+
message: "The .env is tracked by git. Run `git rm --cached .env` and add it to .gitignore."
|
|
162
|
+
});
|
|
89
163
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
return found;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "unignored-dotenv",
|
|
169
|
+
description: "A worktree's .env is neither tracked nor ignored by git.",
|
|
170
|
+
async check(ctx) {
|
|
171
|
+
const found = [];
|
|
172
|
+
for (const { name, path } of ctx.dotenvs) {
|
|
173
|
+
if (await ctx.isTracked(path, ".env"))
|
|
174
|
+
continue;
|
|
175
|
+
if (await ctx.isIgnored(path, ".env"))
|
|
176
|
+
continue;
|
|
177
|
+
found.push({
|
|
178
|
+
location: `${name}/.env`,
|
|
179
|
+
message: "The .env is not ignored by git. Add `.env` to .gitignore."
|
|
180
|
+
});
|
|
105
181
|
}
|
|
106
|
-
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "weak-secret",
|
|
187
|
+
description: "A sensitive variable is empty or has an easily guessed value.",
|
|
188
|
+
check: (ctx) => entries(ctx).filter((e) => isSensitiveKey(e.key) && WEAK_VALUES.has(e.text.trim().toLowerCase())).map((e) => ({
|
|
189
|
+
location: e.location,
|
|
190
|
+
message: `${e.key} has an empty or easily guessed value.`
|
|
191
|
+
}))
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "shared-secret",
|
|
195
|
+
description: "A sensitive variable is in defaults, so it goes to every worktree using that profile.",
|
|
196
|
+
check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
|
|
197
|
+
location: e.location,
|
|
198
|
+
message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
|
|
199
|
+
}))
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "secret-pattern",
|
|
203
|
+
description: "A value has the format of a well-known secret.",
|
|
204
|
+
check: (ctx) => entries(ctx).flatMap((e) => SECRET_PATTERNS.filter(([pattern]) => pattern.test(e.text)).map(([, name]) => ({
|
|
205
|
+
location: e.location,
|
|
206
|
+
message: `${e.key} looks like ${name}.`
|
|
207
|
+
})))
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "url-credentials",
|
|
211
|
+
description: "A remote URL embeds a password.",
|
|
212
|
+
check: (ctx) => entries(ctx).filter((e) => {
|
|
213
|
+
const url = parseUrl(e.text);
|
|
214
|
+
return url?.hasPassword && !url.local;
|
|
215
|
+
}).map((e) => ({
|
|
216
|
+
location: e.location,
|
|
217
|
+
message: `${e.key} embeds a password in its URL.`
|
|
218
|
+
}))
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "insecure-url",
|
|
222
|
+
description: "A remote URL uses an unencrypted protocol.",
|
|
223
|
+
check: (ctx) => entries(ctx).filter((e) => {
|
|
224
|
+
const url = parseUrl(e.text);
|
|
225
|
+
return url && !url.local && ["http", "ws", "ftp"].includes(url.scheme);
|
|
226
|
+
}).map((e) => ({
|
|
227
|
+
location: e.location,
|
|
228
|
+
message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
|
|
229
|
+
}))
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: "unknown-profile",
|
|
233
|
+
description: "A worktree uses a profile that is not defined.",
|
|
234
|
+
check: ({ values }) => Object.entries(values.uses ?? {}).filter(([, profile]) => profile !== "default" && !Object.hasOwn(values.profiles ?? {}, profile)).map(([worktree, profile]) => ({
|
|
235
|
+
location: `uses.${worktree}`,
|
|
236
|
+
message: `Profile "${profile}" is not defined under profiles; \`envs edit\` creates it from default.`
|
|
237
|
+
}))
|
|
107
238
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
239
|
+
];
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// src/ui/server.ts
|
|
243
|
+
import { join } from "path";
|
|
244
|
+
import { readdir } from "fs/promises";
|
|
245
|
+
function parseBody(body) {
|
|
246
|
+
if (!isRecord(body) || !isRecord(body.uses) || !isRecord(body.profiles))
|
|
247
|
+
return;
|
|
248
|
+
if (!Object.values(body.uses).every((v) => typeof v === "string"))
|
|
249
|
+
return;
|
|
250
|
+
for (const profile of Object.values(body.profiles)) {
|
|
251
|
+
if (!isRecord(profile) || !isRecord(profile.defaults) || !isRecord(profile.envs))
|
|
252
|
+
return;
|
|
253
|
+
if (!Object.values(profile.defaults).every(isScalar))
|
|
254
|
+
return;
|
|
255
|
+
for (const byWorktree of Object.values(profile.envs)) {
|
|
256
|
+
if (!isRecord(byWorktree) || !Object.values(byWorktree).every(isScalar))
|
|
257
|
+
return;
|
|
121
258
|
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
259
|
+
}
|
|
260
|
+
return body;
|
|
261
|
+
}
|
|
262
|
+
async function loadAssets() {
|
|
263
|
+
const assets = new Map;
|
|
264
|
+
if (await Bun.file(join(import.meta.dir, "app.tsx")).exists()) {
|
|
265
|
+
const result = await Bun.build({ entrypoints: [join(import.meta.dir, "index.html")], target: "browser" });
|
|
266
|
+
if (!result.success)
|
|
267
|
+
throw new AggregateError(result.logs, "Web editor build failed");
|
|
268
|
+
for (const out of result.outputs)
|
|
269
|
+
assets.set(out.path.replace(/^\.\//, ""), out);
|
|
270
|
+
} else {
|
|
271
|
+
const dir = join(import.meta.dir, "ui");
|
|
272
|
+
for (const name of await readdir(dir))
|
|
273
|
+
assets.set(name, Bun.file(join(dir, name)));
|
|
274
|
+
}
|
|
275
|
+
return assets;
|
|
276
|
+
}
|
|
277
|
+
async function serveUi(ctx) {
|
|
278
|
+
const assets = await loadAssets();
|
|
279
|
+
const server = Bun.serve({
|
|
280
|
+
hostname: "127.0.0.1",
|
|
281
|
+
port: 0,
|
|
282
|
+
routes: {
|
|
283
|
+
"/api/values": {
|
|
284
|
+
GET: async () => Response.json(await loadUiState(ctx)),
|
|
285
|
+
PUT: async (req) => {
|
|
286
|
+
const values = parseBody(await req.json().catch(() => {
|
|
287
|
+
return;
|
|
288
|
+
}));
|
|
289
|
+
if (!values)
|
|
290
|
+
return Response.json({ error: "Invalid values" }, { status: 400 });
|
|
291
|
+
const logs = [];
|
|
292
|
+
const errors = [];
|
|
293
|
+
const capture = {
|
|
294
|
+
...ctx,
|
|
295
|
+
log: (m) => logs.push(m.replace(ANSI, "")),
|
|
296
|
+
error: (m) => errors.push(m.replace(ANSI, ""))
|
|
297
|
+
};
|
|
298
|
+
const { created, code } = await saveUiState(capture, values);
|
|
299
|
+
return Response.json({ ok: code === 0, created, logs, errors }, { status: code === 0 ? 200 : 500 });
|
|
300
|
+
}
|
|
151
301
|
}
|
|
152
|
-
|
|
302
|
+
},
|
|
303
|
+
fetch: (req) => {
|
|
304
|
+
const path = new URL(req.url).pathname.slice(1) || "index.html";
|
|
305
|
+
const asset = assets.get(path);
|
|
306
|
+
return asset ? new Response(asset) : new Response("Not found", { status: 404 });
|
|
153
307
|
}
|
|
154
|
-
}
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
308
|
+
});
|
|
309
|
+
const url = `http://127.0.0.1:${server.port}`;
|
|
310
|
+
ctx.log(`Web editor running at ${url} (Ctrl+C to stop)`);
|
|
311
|
+
await ctx.openUrl?.(url);
|
|
312
|
+
await (ctx.waitForExit ?? (() => new Promise(() => {})))();
|
|
313
|
+
await server.stop(true);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
var ANSI, isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v), isScalar = (v) => ["string", "number", "boolean"].includes(typeof v);
|
|
317
|
+
var init_server = __esm(() => {
|
|
318
|
+
init_envs();
|
|
319
|
+
ANSI = /\x1b\[[0-9;]*m/g;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// src/envs.ts
|
|
323
|
+
import { mkdir } from "fs/promises";
|
|
324
|
+
import { basename, dirname, join as join2 } from "path";
|
|
325
|
+
import { stat } from "fs/promises";
|
|
326
|
+
var {$ } = globalThis.Bun;
|
|
327
|
+
function normalizeValues(raw) {
|
|
328
|
+
const profiles = {};
|
|
329
|
+
const add = (name, profile) => {
|
|
330
|
+
const target = profiles[name] ??= { defaults: {}, envs: {} };
|
|
331
|
+
Object.assign(target.defaults, profile?.defaults);
|
|
332
|
+
for (const [key, byWorktree] of Object.entries(profile?.envs ?? {})) {
|
|
333
|
+
Object.assign(target.envs[key] ??= {}, byWorktree);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
add(DEFAULT_PROFILE, { defaults: raw.defaults, envs: raw.envs });
|
|
337
|
+
for (const [name, profile] of Object.entries(raw.profiles ?? {})) {
|
|
338
|
+
add(name, profile);
|
|
339
|
+
}
|
|
340
|
+
return { uses: { ...raw.uses }, profiles };
|
|
341
|
+
}
|
|
342
|
+
function materializeProfiles(values) {
|
|
343
|
+
const created = [];
|
|
344
|
+
for (const name of Object.values(values.uses).map(String)) {
|
|
345
|
+
if (hasProfile(values, name))
|
|
346
|
+
continue;
|
|
347
|
+
values.profiles[name] = structuredClone(values.profiles[DEFAULT_PROFILE]);
|
|
348
|
+
created.push(name);
|
|
349
|
+
}
|
|
350
|
+
return created;
|
|
351
|
+
}
|
|
352
|
+
function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
|
|
353
|
+
const entries = {};
|
|
354
|
+
for (const name of new Set([DEFAULT_PROFILE, profile])) {
|
|
355
|
+
const layer = values.profiles[name];
|
|
356
|
+
for (const [key, value] of Object.entries(layer.defaults)) {
|
|
357
|
+
entries[key] = String(value);
|
|
358
|
+
}
|
|
359
|
+
if (name === profile && !includeProfileEnvs)
|
|
360
|
+
continue;
|
|
361
|
+
for (const [key, byWorktree] of Object.entries(layer.envs)) {
|
|
362
|
+
if (byWorktree && worktree in byWorktree) {
|
|
363
|
+
entries[key] = String(byWorktree[worktree]);
|
|
169
364
|
}
|
|
170
|
-
return found;
|
|
171
365
|
}
|
|
172
|
-
},
|
|
173
|
-
{
|
|
174
|
-
id: "weak-secret",
|
|
175
|
-
description: "A sensitive variable is empty or has an easily guessed value.",
|
|
176
|
-
check: (ctx) => entries(ctx).filter((e) => isSensitiveKey(e.key) && WEAK_VALUES.has(e.text.trim().toLowerCase())).map((e) => ({
|
|
177
|
-
location: e.location,
|
|
178
|
-
message: `${e.key} has an empty or easily guessed value.`
|
|
179
|
-
}))
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
id: "shared-secret",
|
|
183
|
-
description: "A sensitive variable is in defaults, so it goes to every worktree.",
|
|
184
|
-
check: (ctx) => entries(ctx).filter((e) => e.shared && isSensitiveKey(e.key)).map((e) => ({
|
|
185
|
-
location: e.location,
|
|
186
|
-
message: `${e.key} looks sensitive but is written to every worktree; set it per worktree under envs instead.`
|
|
187
|
-
}))
|
|
188
|
-
},
|
|
189
|
-
{
|
|
190
|
-
id: "secret-pattern",
|
|
191
|
-
description: "A value has the format of a well-known secret.",
|
|
192
|
-
check: (ctx) => entries(ctx).flatMap((e) => SECRET_PATTERNS.filter(([pattern]) => pattern.test(e.text)).map(([, name]) => ({
|
|
193
|
-
location: e.location,
|
|
194
|
-
message: `${e.key} looks like ${name}.`
|
|
195
|
-
})))
|
|
196
|
-
},
|
|
197
|
-
{
|
|
198
|
-
id: "url-credentials",
|
|
199
|
-
description: "A remote URL embeds a password.",
|
|
200
|
-
check: (ctx) => entries(ctx).filter((e) => {
|
|
201
|
-
const url = parseUrl(e.text);
|
|
202
|
-
return url?.hasPassword && !url.local;
|
|
203
|
-
}).map((e) => ({
|
|
204
|
-
location: e.location,
|
|
205
|
-
message: `${e.key} embeds a password in its URL.`
|
|
206
|
-
}))
|
|
207
|
-
},
|
|
208
|
-
{
|
|
209
|
-
id: "insecure-url",
|
|
210
|
-
description: "A remote URL uses an unencrypted protocol.",
|
|
211
|
-
check: (ctx) => entries(ctx).filter((e) => {
|
|
212
|
-
const url = parseUrl(e.text);
|
|
213
|
-
return url && !url.local && ["http", "ws", "ftp"].includes(url.scheme);
|
|
214
|
-
}).map((e) => ({
|
|
215
|
-
location: e.location,
|
|
216
|
-
message: `${e.key} uses ${parseUrl(e.text).scheme}:// instead of an encrypted protocol.`
|
|
217
|
-
}))
|
|
218
366
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
var yellow = (text) => paint(33, text);
|
|
238
|
-
var defaultContext = () => ({
|
|
239
|
-
cwd: process.cwd(),
|
|
240
|
-
log: (message) => console.log(message),
|
|
241
|
-
error: (message) => console.error(message),
|
|
242
|
-
openEditor: async (path) => {
|
|
243
|
-
const proc = Bun.spawn(["code", "-w", path], {
|
|
244
|
-
env: { ...process.env },
|
|
245
|
-
stdio: ["inherit", "inherit", "inherit"]
|
|
246
|
-
});
|
|
247
|
-
return await proc.exited;
|
|
367
|
+
return entries;
|
|
368
|
+
}
|
|
369
|
+
function stringifyValues(values) {
|
|
370
|
+
const profiles = Object.fromEntries(Object.entries(values.profiles).map(([name, { defaults, envs }]) => {
|
|
371
|
+
const kept = Object.fromEntries(Object.entries(envs).filter(([, byWorktree]) => Object.keys(byWorktree).length > 0));
|
|
372
|
+
return [name, { defaults, envs: kept }];
|
|
373
|
+
}));
|
|
374
|
+
const out = Object.keys(values.uses).length > 0 ? { uses: values.uses, profiles } : { profiles };
|
|
375
|
+
return SCHEMA_COMMENT + Bun.YAML.stringify(out, null, 2);
|
|
376
|
+
}
|
|
377
|
+
function checkProfiles(ctx, values, worktrees) {
|
|
378
|
+
let ok = true;
|
|
379
|
+
for (const { name } of worktrees) {
|
|
380
|
+
const profile = values.uses[name] ?? DEFAULT_PROFILE;
|
|
381
|
+
if (hasProfile(values, profile))
|
|
382
|
+
continue;
|
|
383
|
+
ok = false;
|
|
384
|
+
ctx.error(`Unknown profile "${profile}" for worktree ${name}. Available: ${Object.keys(values.profiles).join(", ")}. Run \`envs edit\` to create it.`);
|
|
248
385
|
}
|
|
249
|
-
|
|
386
|
+
return ok;
|
|
387
|
+
}
|
|
250
388
|
async function findRoot(cwd) {
|
|
251
389
|
const result = await $`git rev-parse --path-format=absolute --git-common-dir`.cwd(cwd).nothrow().quiet();
|
|
252
390
|
if (result.exitCode !== 0)
|
|
@@ -325,7 +463,7 @@ function updateDotenv(text, entries) {
|
|
|
325
463
|
`;
|
|
326
464
|
}
|
|
327
465
|
async function readValues(ctx) {
|
|
328
|
-
const path =
|
|
466
|
+
const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
|
|
329
467
|
if (!await Bun.file(path).exists()) {
|
|
330
468
|
ctx.error(`${path} not found. Run \`envs init\` first.`);
|
|
331
469
|
return;
|
|
@@ -336,10 +474,10 @@ async function readValues(ctx) {
|
|
|
336
474
|
async function init(ctx) {
|
|
337
475
|
const root = await findRoot(ctx.cwd);
|
|
338
476
|
const files = { ".envs/.gitignore": `*
|
|
339
|
-
`, ".envs/values.yml":
|
|
340
|
-
await mkdir(
|
|
477
|
+
`, ".envs/values.yml": SCHEMA_COMMENT };
|
|
478
|
+
await mkdir(join2(root, ".envs"), { recursive: true });
|
|
341
479
|
for (const [name, content] of Object.entries(files)) {
|
|
342
|
-
const path =
|
|
480
|
+
const path = join2(root, name);
|
|
343
481
|
if (await Bun.file(path).exists()) {
|
|
344
482
|
ctx.log(`${path} already exists`);
|
|
345
483
|
} else {
|
|
@@ -349,24 +487,22 @@ async function init(ctx) {
|
|
|
349
487
|
}
|
|
350
488
|
return 0;
|
|
351
489
|
}
|
|
352
|
-
async function push(ctx) {
|
|
490
|
+
async function push(ctx, only) {
|
|
353
491
|
const read = await readValues(ctx);
|
|
354
492
|
if (!read)
|
|
355
493
|
return 1;
|
|
356
|
-
const
|
|
494
|
+
const values = normalizeValues(read.values);
|
|
495
|
+
const worktrees = (await listWorktrees(ctx.cwd)).filter(({ name }) => only === undefined || name === only);
|
|
496
|
+
if (!checkProfiles(ctx, values, worktrees))
|
|
497
|
+
return 1;
|
|
357
498
|
const changes = new Map;
|
|
358
|
-
for (const { name, path } of
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
entries[key] = String(value);
|
|
362
|
-
}
|
|
363
|
-
for (const [key, byWorktree] of Object.entries(values.envs ?? {})) {
|
|
364
|
-
if (name in byWorktree)
|
|
365
|
-
entries[key] = String(byWorktree[name]);
|
|
366
|
-
}
|
|
499
|
+
for (const { name, path } of worktrees) {
|
|
500
|
+
const profile = values.uses[name] ?? DEFAULT_PROFILE;
|
|
501
|
+
const entries = resolveEntries(values, name, profile);
|
|
367
502
|
if (Object.keys(entries).length === 0)
|
|
368
503
|
continue;
|
|
369
|
-
const
|
|
504
|
+
const label = profile === DEFAULT_PROFILE ? name : `${name} [${profile}]`;
|
|
505
|
+
const dotenv = Bun.file(join2(path, ".env"));
|
|
370
506
|
const current = await dotenv.exists() ? await dotenv.text() : "";
|
|
371
507
|
const existing = parseDotenv(current);
|
|
372
508
|
for (const [key, value] of Object.entries(entries)) {
|
|
@@ -374,7 +510,7 @@ async function push(ctx) {
|
|
|
374
510
|
continue;
|
|
375
511
|
const shown = isMaskedKey(key) ? MASK : formatDotenvValue(value);
|
|
376
512
|
const line = `${key}=${shown}`;
|
|
377
|
-
changes.set(line, [...changes.get(line) ?? [],
|
|
513
|
+
changes.set(line, [...changes.get(line) ?? [], label]);
|
|
378
514
|
}
|
|
379
515
|
await Bun.write(dotenv, updateDotenv(current, entries));
|
|
380
516
|
}
|
|
@@ -387,27 +523,29 @@ async function pull(ctx) {
|
|
|
387
523
|
const read = await readValues(ctx);
|
|
388
524
|
if (!read)
|
|
389
525
|
return 1;
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
|
|
526
|
+
const values = normalizeValues(read.values);
|
|
527
|
+
const worktrees = await listWorktrees(ctx.cwd);
|
|
528
|
+
materializeProfiles(values);
|
|
393
529
|
const pulledNames = [];
|
|
394
530
|
const pulledKeys = new Set;
|
|
395
|
-
for (const { name, path } of
|
|
396
|
-
const
|
|
531
|
+
for (const { name, path } of worktrees) {
|
|
532
|
+
const profile = values.uses[name] ??= DEFAULT_PROFILE;
|
|
533
|
+
const dotenv = Bun.file(join2(path, ".env"));
|
|
397
534
|
if (!await dotenv.exists())
|
|
398
535
|
continue;
|
|
399
536
|
const parsed = parseDotenv(await dotenv.text());
|
|
400
537
|
pulledNames.push(name);
|
|
401
538
|
for (const key of Object.keys(parsed))
|
|
402
539
|
pulledKeys.add(key);
|
|
540
|
+
const { envs } = values.profiles[profile];
|
|
403
541
|
for (const [key, byWorktree] of Object.entries(envs)) {
|
|
404
542
|
if (!(key in parsed))
|
|
405
543
|
delete byWorktree[name];
|
|
406
544
|
}
|
|
545
|
+
const base = resolveEntries(values, name, profile, false);
|
|
407
546
|
for (const [key, value] of Object.entries(parsed)) {
|
|
408
547
|
const parsedValue = parseValue(value);
|
|
409
|
-
|
|
410
|
-
if (fromDefaults) {
|
|
548
|
+
if (key in base && base[key] === String(parsedValue)) {
|
|
411
549
|
if (envs[key])
|
|
412
550
|
delete envs[key][name];
|
|
413
551
|
} else {
|
|
@@ -415,16 +553,40 @@ async function pull(ctx) {
|
|
|
415
553
|
}
|
|
416
554
|
}
|
|
417
555
|
}
|
|
418
|
-
|
|
419
|
-
if (Object.keys(byWorktree).length === 0)
|
|
420
|
-
delete envs[key];
|
|
421
|
-
}
|
|
422
|
-
await Bun.write(valuesPath, Bun.YAML.stringify(values, null, 2));
|
|
556
|
+
await Bun.write(read.path, stringifyValues(values));
|
|
423
557
|
if (pulledNames.length > 0) {
|
|
424
558
|
ctx.log(green(`\u2193 pulling ${pulledNames.join(", ")} - ${pulledKeys.size} variables`));
|
|
425
559
|
}
|
|
426
560
|
return 0;
|
|
427
561
|
}
|
|
562
|
+
async function use(ctx, profile) {
|
|
563
|
+
const read = await readValues(ctx);
|
|
564
|
+
if (!read)
|
|
565
|
+
return 1;
|
|
566
|
+
const values = normalizeValues(read.values);
|
|
567
|
+
const worktrees = await listWorktrees(ctx.cwd);
|
|
568
|
+
if (profile === undefined) {
|
|
569
|
+
for (const { name } of worktrees) {
|
|
570
|
+
ctx.log(`${name}: ${values.uses[name] ?? DEFAULT_PROFILE}`);
|
|
571
|
+
}
|
|
572
|
+
return 0;
|
|
573
|
+
}
|
|
574
|
+
if (!hasProfile(values, profile)) {
|
|
575
|
+
ctx.error(`Unknown profile "${profile}". Available: ${Object.keys(values.profiles).join(", ")}. Add it in \`envs edit\` first.`);
|
|
576
|
+
return 1;
|
|
577
|
+
}
|
|
578
|
+
const top = await $`git rev-parse --show-toplevel`.cwd(ctx.cwd).nothrow().quiet();
|
|
579
|
+
const current = worktrees.find(({ path }) => path === top.stdout.toString().trim());
|
|
580
|
+
if (!current) {
|
|
581
|
+
ctx.error("Run `envs use` inside a git worktree.");
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
for (const { name } of worktrees)
|
|
585
|
+
values.uses[name] ??= DEFAULT_PROFILE;
|
|
586
|
+
values.uses[current.name] = profile;
|
|
587
|
+
await Bun.write(read.path, stringifyValues(values));
|
|
588
|
+
return await push(ctx, current.name);
|
|
589
|
+
}
|
|
428
590
|
async function isIgnored(dir, file) {
|
|
429
591
|
const result = await $`git check-ignore -q ${file}`.cwd(dir).nothrow().quiet();
|
|
430
592
|
return result.exitCode === 0;
|
|
@@ -442,7 +604,7 @@ async function lint(ctx) {
|
|
|
442
604
|
const modeOf = async (path) => process.platform === "win32" ? undefined : (await stat(path)).mode;
|
|
443
605
|
const dotenvs = [];
|
|
444
606
|
for (const worktree of worktrees) {
|
|
445
|
-
const path =
|
|
607
|
+
const path = join2(worktree.path, ".env");
|
|
446
608
|
if (await Bun.file(path).exists()) {
|
|
447
609
|
dotenvs.push({ ...worktree, mode: await modeOf(path) });
|
|
448
610
|
}
|
|
@@ -470,13 +632,17 @@ async function lint(ctx) {
|
|
|
470
632
|
${count} warning(s) found.`);
|
|
471
633
|
return 1;
|
|
472
634
|
}
|
|
473
|
-
async function edit(ctx) {
|
|
474
|
-
const valuesPath =
|
|
635
|
+
async function edit(ctx, options = {}) {
|
|
636
|
+
const valuesPath = join2(await findRoot(ctx.cwd), ".envs/values.yml");
|
|
475
637
|
if (!await Bun.file(valuesPath).exists())
|
|
476
638
|
await init(ctx);
|
|
477
639
|
const pulled = await pull(ctx);
|
|
478
640
|
if (pulled !== 0)
|
|
479
641
|
return pulled;
|
|
642
|
+
if (options.ui) {
|
|
643
|
+
await Promise.resolve().then(() => init_server());
|
|
644
|
+
return await serveUi(ctx);
|
|
645
|
+
}
|
|
480
646
|
const original = await Bun.file(valuesPath).text();
|
|
481
647
|
await Bun.write(valuesPath, EDIT_NOTICE + original);
|
|
482
648
|
const code = await ctx.openEditor(valuesPath);
|
|
@@ -486,13 +652,33 @@ async function edit(ctx) {
|
|
|
486
652
|
}
|
|
487
653
|
if (code !== 0)
|
|
488
654
|
return code;
|
|
655
|
+
const raw = Bun.YAML.parse(await Bun.file(valuesPath).text()) ?? {};
|
|
656
|
+
const values = normalizeValues(raw);
|
|
657
|
+
const created = materializeProfiles(values);
|
|
658
|
+
if (created.length > 0 || !hasCompleteProfiles(raw)) {
|
|
659
|
+
await Bun.write(valuesPath, stringifyValues(values));
|
|
660
|
+
}
|
|
661
|
+
if (created.length > 0) {
|
|
662
|
+
ctx.log(green(`+ created ${created.join(", ")} from ${DEFAULT_PROFILE}`));
|
|
663
|
+
}
|
|
489
664
|
return await push(ctx);
|
|
490
665
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
666
|
+
async function loadUiState(ctx) {
|
|
667
|
+
const read = await readValues(ctx);
|
|
668
|
+
if (!read)
|
|
669
|
+
throw new Error("Run `envs init` first.");
|
|
670
|
+
const [main, ...others] = (await listWorktrees(ctx.cwd)).map(({ name }) => name);
|
|
671
|
+
others.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
672
|
+
const worktrees = main === undefined ? [] : [main, ...others];
|
|
673
|
+
return { ...normalizeValues(read.values), worktrees };
|
|
674
|
+
}
|
|
675
|
+
async function saveUiState(ctx, input) {
|
|
676
|
+
const values = normalizeValues({ uses: input.uses, profiles: input.profiles });
|
|
677
|
+
const created = materializeProfiles(values);
|
|
678
|
+
const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
|
|
679
|
+
await Bun.write(path, stringifyValues(values));
|
|
680
|
+
return { created, code: await push(ctx) };
|
|
681
|
+
}
|
|
496
682
|
async function run(args, ctx = defaultContext()) {
|
|
497
683
|
const [command = "help"] = args;
|
|
498
684
|
switch (command) {
|
|
@@ -510,7 +696,9 @@ async function run(args, ctx = defaultContext()) {
|
|
|
510
696
|
case "lint":
|
|
511
697
|
return await lint(ctx);
|
|
512
698
|
case "edit":
|
|
513
|
-
return await edit(ctx);
|
|
699
|
+
return await edit(ctx, { ui: args.includes("--ui") });
|
|
700
|
+
case "use":
|
|
701
|
+
return await use(ctx, args[1]);
|
|
514
702
|
default:
|
|
515
703
|
ctx.error(`Unknown command: ${command}
|
|
516
704
|
|
|
@@ -518,6 +706,48 @@ ${HELP}`);
|
|
|
518
706
|
return 1;
|
|
519
707
|
}
|
|
520
708
|
}
|
|
709
|
+
var HELP = `Usage: envs <command>
|
|
710
|
+
|
|
711
|
+
Commands:
|
|
712
|
+
init Create the .envs/ directory (.gitignore, values.yml)
|
|
713
|
+
pull Read each worktree's .env and write it into .envs/values.yml
|
|
714
|
+
push Write defaults and .envs/values.yml into the .env of each worktree
|
|
715
|
+
lint Warn about insecure values and unprotected secret files
|
|
716
|
+
edit Pull, open .envs/values.yml in VS Code (code -w), then push
|
|
717
|
+
With --ui, serve a web editor instead; its Save button pushes
|
|
718
|
+
use Select the profile of the current worktree (envs use <profile>), or list them
|
|
719
|
+
help Show this help message
|
|
720
|
+
`, colorsDisabled = () => Boolean(process.env.NO_COLOR), MASK = "********", isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key), paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`, green = (text) => paint(32, text), yellow = (text) => paint(33, text), defaultContext = () => ({
|
|
721
|
+
cwd: process.cwd(),
|
|
722
|
+
log: (message) => console.log(message),
|
|
723
|
+
error: (message) => console.error(message),
|
|
724
|
+
openEditor: async (path) => {
|
|
725
|
+
const proc = Bun.spawn(["code", "-w", path], {
|
|
726
|
+
env: { ...process.env },
|
|
727
|
+
stdio: ["inherit", "inherit", "inherit"]
|
|
728
|
+
});
|
|
729
|
+
return await proc.exited;
|
|
730
|
+
},
|
|
731
|
+
openUrl: async (url) => {
|
|
732
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
733
|
+
try {
|
|
734
|
+
await Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] }).exited;
|
|
735
|
+
} catch {}
|
|
736
|
+
},
|
|
737
|
+
waitForExit: () => new Promise((resolve) => {
|
|
738
|
+
process.once("SIGINT", () => resolve());
|
|
739
|
+
process.once("SIGTERM", () => resolve());
|
|
740
|
+
})
|
|
741
|
+
}), DEFAULT_PROFILE = "default", 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
|
|
742
|
+
`, 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
|
|
743
|
+
# Close this file when your changes are ready, then they are pushed.
|
|
744
|
+
# To cancel the edit, press Ctrl+C in the terminal and then close this file.
|
|
745
|
+
# \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
|
|
746
|
+
`;
|
|
747
|
+
var init_envs = __esm(() => {
|
|
748
|
+
init_lint_rules();
|
|
749
|
+
});
|
|
521
750
|
|
|
522
751
|
// src/bin/envs.ts
|
|
752
|
+
init_envs();
|
|
523
753
|
process.exit(await run(Bun.argv.slice(2)));
|