@jondotsoy/envs 0.1.7 → 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 +1 -0
- package/envs.js +340 -226
- 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,40 +1,18 @@
|
|
|
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
|
}
|
|
@@ -74,196 +52,278 @@ function parseUrl(text) {
|
|
|
74
52
|
local: /^(localhost|127\.\d+\.\d+\.\d+|::1|0\.0\.0\.0)$/.test(hostname)
|
|
75
53
|
};
|
|
76
54
|
}
|
|
77
|
-
var valuesFile = ".envs/values.yml";
|
|
78
55
|
function envFiles(ctx) {
|
|
79
56
|
return [
|
|
80
57
|
{ location: valuesFile, mode: ctx.valuesMode },
|
|
81
58
|
...ctx.dotenvs.map((d) => ({ location: `${d.name}/.env`, mode: d.mode }))
|
|
82
59
|
];
|
|
83
60
|
}
|
|
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
|
-
|
|
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
|
+
});
|
|
111
163
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
});
|
|
125
181
|
}
|
|
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
|
-
});
|
|
182
|
+
return found;
|
|
157
183
|
}
|
|
158
|
-
|
|
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
|
+
}))
|
|
159
238
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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;
|
|
177
258
|
}
|
|
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
259
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
var colorsDisabled = () => Boolean(process.env.NO_COLOR);
|
|
248
|
-
var MASK = "********";
|
|
249
|
-
var isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key);
|
|
250
|
-
var paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`;
|
|
251
|
-
var green = (text) => paint(32, text);
|
|
252
|
-
var yellow = (text) => paint(33, text);
|
|
253
|
-
var defaultContext = () => ({
|
|
254
|
-
cwd: process.cwd(),
|
|
255
|
-
log: (message) => console.log(message),
|
|
256
|
-
error: (message) => console.error(message),
|
|
257
|
-
openEditor: async (path) => {
|
|
258
|
-
const proc = Bun.spawn(["code", "-w", path], {
|
|
259
|
-
env: { ...process.env },
|
|
260
|
-
stdio: ["inherit", "inherit", "inherit"]
|
|
261
|
-
});
|
|
262
|
-
return await proc.exited;
|
|
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)));
|
|
263
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
|
+
}
|
|
301
|
+
}
|
|
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 });
|
|
307
|
+
}
|
|
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;
|
|
264
320
|
});
|
|
265
|
-
|
|
266
|
-
|
|
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;
|
|
267
327
|
function normalizeValues(raw) {
|
|
268
328
|
const profiles = {};
|
|
269
329
|
const add = (name, profile) => {
|
|
@@ -306,7 +366,6 @@ function resolveEntries(values, worktree, profile, includeProfileEnvs = true) {
|
|
|
306
366
|
}
|
|
307
367
|
return entries;
|
|
308
368
|
}
|
|
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
369
|
function stringifyValues(values) {
|
|
311
370
|
const profiles = Object.fromEntries(Object.entries(values.profiles).map(([name, { defaults, envs }]) => {
|
|
312
371
|
const kept = Object.fromEntries(Object.entries(envs).filter(([, byWorktree]) => Object.keys(byWorktree).length > 0));
|
|
@@ -315,8 +374,6 @@ function stringifyValues(values) {
|
|
|
315
374
|
const out = Object.keys(values.uses).length > 0 ? { uses: values.uses, profiles } : { profiles };
|
|
316
375
|
return SCHEMA_COMMENT + Bun.YAML.stringify(out, null, 2);
|
|
317
376
|
}
|
|
318
|
-
var SCHEMA_COMMENT = `# yaml-language-server: $schema=https://jondotsoy.github.io/envs/schema.json
|
|
319
|
-
`;
|
|
320
377
|
function checkProfiles(ctx, values, worktrees) {
|
|
321
378
|
let ok = true;
|
|
322
379
|
for (const { name } of worktrees) {
|
|
@@ -406,7 +463,7 @@ function updateDotenv(text, entries) {
|
|
|
406
463
|
`;
|
|
407
464
|
}
|
|
408
465
|
async function readValues(ctx) {
|
|
409
|
-
const path =
|
|
466
|
+
const path = join2(await findRoot(ctx.cwd), ".envs/values.yml");
|
|
410
467
|
if (!await Bun.file(path).exists()) {
|
|
411
468
|
ctx.error(`${path} not found. Run \`envs init\` first.`);
|
|
412
469
|
return;
|
|
@@ -418,9 +475,9 @@ async function init(ctx) {
|
|
|
418
475
|
const root = await findRoot(ctx.cwd);
|
|
419
476
|
const files = { ".envs/.gitignore": `*
|
|
420
477
|
`, ".envs/values.yml": SCHEMA_COMMENT };
|
|
421
|
-
await mkdir(
|
|
478
|
+
await mkdir(join2(root, ".envs"), { recursive: true });
|
|
422
479
|
for (const [name, content] of Object.entries(files)) {
|
|
423
|
-
const path =
|
|
480
|
+
const path = join2(root, name);
|
|
424
481
|
if (await Bun.file(path).exists()) {
|
|
425
482
|
ctx.log(`${path} already exists`);
|
|
426
483
|
} else {
|
|
@@ -445,7 +502,7 @@ async function push(ctx, only) {
|
|
|
445
502
|
if (Object.keys(entries).length === 0)
|
|
446
503
|
continue;
|
|
447
504
|
const label = profile === DEFAULT_PROFILE ? name : `${name} [${profile}]`;
|
|
448
|
-
const dotenv = Bun.file(
|
|
505
|
+
const dotenv = Bun.file(join2(path, ".env"));
|
|
449
506
|
const current = await dotenv.exists() ? await dotenv.text() : "";
|
|
450
507
|
const existing = parseDotenv(current);
|
|
451
508
|
for (const [key, value] of Object.entries(entries)) {
|
|
@@ -473,7 +530,7 @@ async function pull(ctx) {
|
|
|
473
530
|
const pulledKeys = new Set;
|
|
474
531
|
for (const { name, path } of worktrees) {
|
|
475
532
|
const profile = values.uses[name] ??= DEFAULT_PROFILE;
|
|
476
|
-
const dotenv = Bun.file(
|
|
533
|
+
const dotenv = Bun.file(join2(path, ".env"));
|
|
477
534
|
if (!await dotenv.exists())
|
|
478
535
|
continue;
|
|
479
536
|
const parsed = parseDotenv(await dotenv.text());
|
|
@@ -547,7 +604,7 @@ async function lint(ctx) {
|
|
|
547
604
|
const modeOf = async (path) => process.platform === "win32" ? undefined : (await stat(path)).mode;
|
|
548
605
|
const dotenvs = [];
|
|
549
606
|
for (const worktree of worktrees) {
|
|
550
|
-
const path =
|
|
607
|
+
const path = join2(worktree.path, ".env");
|
|
551
608
|
if (await Bun.file(path).exists()) {
|
|
552
609
|
dotenvs.push({ ...worktree, mode: await modeOf(path) });
|
|
553
610
|
}
|
|
@@ -575,13 +632,17 @@ async function lint(ctx) {
|
|
|
575
632
|
${count} warning(s) found.`);
|
|
576
633
|
return 1;
|
|
577
634
|
}
|
|
578
|
-
async function edit(ctx) {
|
|
579
|
-
const valuesPath =
|
|
635
|
+
async function edit(ctx, options = {}) {
|
|
636
|
+
const valuesPath = join2(await findRoot(ctx.cwd), ".envs/values.yml");
|
|
580
637
|
if (!await Bun.file(valuesPath).exists())
|
|
581
638
|
await init(ctx);
|
|
582
639
|
const pulled = await pull(ctx);
|
|
583
640
|
if (pulled !== 0)
|
|
584
641
|
return pulled;
|
|
642
|
+
if (options.ui) {
|
|
643
|
+
await Promise.resolve().then(() => init_server());
|
|
644
|
+
return await serveUi(ctx);
|
|
645
|
+
}
|
|
585
646
|
const original = await Bun.file(valuesPath).text();
|
|
586
647
|
await Bun.write(valuesPath, EDIT_NOTICE + original);
|
|
587
648
|
const code = await ctx.openEditor(valuesPath);
|
|
@@ -602,11 +663,22 @@ async function edit(ctx) {
|
|
|
602
663
|
}
|
|
603
664
|
return await push(ctx);
|
|
604
665
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
+
}
|
|
610
682
|
async function run(args, ctx = defaultContext()) {
|
|
611
683
|
const [command = "help"] = args;
|
|
612
684
|
switch (command) {
|
|
@@ -624,7 +696,7 @@ async function run(args, ctx = defaultContext()) {
|
|
|
624
696
|
case "lint":
|
|
625
697
|
return await lint(ctx);
|
|
626
698
|
case "edit":
|
|
627
|
-
return await edit(ctx);
|
|
699
|
+
return await edit(ctx, { ui: args.includes("--ui") });
|
|
628
700
|
case "use":
|
|
629
701
|
return await use(ctx, args[1]);
|
|
630
702
|
default:
|
|
@@ -634,6 +706,48 @@ ${HELP}`);
|
|
|
634
706
|
return 1;
|
|
635
707
|
}
|
|
636
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
|
+
});
|
|
637
750
|
|
|
638
751
|
// src/bin/envs.ts
|
|
752
|
+
init_envs();
|
|
639
753
|
process.exit(await run(Bun.argv.slice(2)));
|