@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/envs.js CHANGED
@@ -1,57 +1,41 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
-
4
- // src/envs.ts
5
- import { mkdir } from "fs/promises";
6
- import { basename, dirname, join } from "path";
7
- import { stat } from "fs/promises";
8
- var {$ } = globalThis.Bun;
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
- for (const [key, value] of Object.entries(values.defaults ?? {})) {
44
- result.push({ key, text: String(value), location: `defaults.${key}`, shared: true });
45
- }
46
- for (const [key, byWorktree] of Object.entries(values.envs ?? {})) {
47
- for (const [worktree, value] of Object.entries(byWorktree ?? {})) {
48
- result.push({
49
- key,
50
- text: String(value),
51
- location: `envs.${key}.${worktree}`,
52
- shared: false
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 rules = [
79
- {
80
- id: "tracked-values",
81
- description: "values.yml is tracked by git.",
82
- async check(ctx) {
83
- if (!await ctx.isTracked(ctx.root, valuesFile))
84
- return [];
85
- return [
86
- {
87
- location: valuesFile,
88
- message: "values.yml is tracked by git, so its secrets are in the history. Run `git rm --cached .envs/values.yml`."
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
- id: "unignored-values",
95
- description: "values.yml is neither tracked nor ignored by git.",
96
- async check(ctx) {
97
- if (await ctx.isTracked(ctx.root, valuesFile))
98
- return [];
99
- if (await ctx.isIgnored(ctx.root, valuesFile))
100
- return [];
101
- return [
102
- {
103
- location: valuesFile,
104
- message: "values.yml is not ignored by git and could be committed by accident. Add `*` to .envs/.gitignore."
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
- id: "open-permissions",
111
- description: "values.yml is readable by other users.",
112
- check({ valuesMode }) {
113
- if (valuesMode === undefined || (valuesMode & 63) === 0)
114
- return [];
115
- return [
116
- {
117
- location: valuesFile,
118
- message: `values.yml is readable by other users (mode ${(valuesMode & 511).toString(8)}). Run \`chmod 600 .envs/values.yml\`.`
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
- id: "executable-files",
125
- description: "values.yml or a worktree's .env has execute permission.",
126
- check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 73) !== 0).map((f) => ({
127
- location: f.location,
128
- message: `The file is executable (mode ${(f.mode & 511).toString(8)}); env files are data and must not be. Run \`chmod -x\` on it.`
129
- }))
130
- },
131
- {
132
- id: "writable-files",
133
- description: "values.yml or a worktree's .env is writable by other users.",
134
- check: (ctx) => envFiles(ctx).filter((f) => f.mode !== undefined && (f.mode & 18) !== 0).map((f) => ({
135
- location: f.location,
136
- 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.`
137
- }))
138
- },
139
- {
140
- id: "tracked-dotenv",
141
- description: "A worktree's .env is tracked by git.",
142
- async check(ctx) {
143
- const found = [];
144
- for (const { name, path } of ctx.dotenvs) {
145
- if (!await ctx.isTracked(path, ".env"))
146
- continue;
147
- found.push({
148
- location: `${name}/.env`,
149
- message: "The .env is tracked by git. Run `git rm --cached .env` and add it to .gitignore."
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
- return found;
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
- id: "unignored-dotenv",
157
- description: "A worktree's .env is neither tracked nor ignored by git.",
158
- async check(ctx) {
159
- const found = [];
160
- for (const { name, path } of ctx.dotenvs) {
161
- if (await ctx.isTracked(path, ".env"))
162
- continue;
163
- if (await ctx.isIgnored(path, ".env"))
164
- continue;
165
- found.push({
166
- location: `${name}/.env`,
167
- message: "The .env is not ignored by git. Add `.env` to .gitignore."
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
- // src/envs.ts
222
- var HELP = `Usage: envs <command>
223
-
224
- Commands:
225
- init Create the .envs/ directory (.gitignore, values.yml)
226
- pull Read each worktree's .env and write it into .envs/values.yml
227
- push Write defaults and .envs/values.yml into the .env of each worktree
228
- lint Warn about insecure values and unprotected secret files
229
- edit Pull, open .envs/values.yml in VS Code (code -w), then push
230
- help Show this help message
231
- `;
232
- var colorsDisabled = () => Boolean(process.env.NO_COLOR);
233
- var MASK = "********";
234
- var isMaskedKey = (key) => isSensitiveKey(key) || /(^|_)KEY(_|$)/i.test(key);
235
- var paint = (code, text) => colorsDisabled() ? text : `\x1B[${code}m${text}\x1B[0m`;
236
- var green = (text) => paint(32, text);
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 = join(await findRoot(ctx.cwd), ".envs/values.yml");
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(join(root, ".envs"), { recursive: true });
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 = join(root, name);
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 { values } = read;
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 await listWorktrees(ctx.cwd)) {
359
- const entries = {};
360
- for (const [key, value] of Object.entries(values.defaults ?? {})) {
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 dotenv = Bun.file(join(path, ".env"));
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) ?? [], name]);
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 { path: valuesPath, values } = read;
391
- const envs = values.envs ??= {};
392
- const defaults = values.defaults ?? {};
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 await listWorktrees(ctx.cwd)) {
396
- const dotenv = Bun.file(join(path, ".env"));
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
- const fromDefaults = key in defaults && String(defaults[key]) === String(parsedValue);
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
- for (const [key, byWorktree] of Object.entries(envs)) {
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 = join(worktree.path, ".env");
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 = join(await findRoot(ctx.cwd), ".envs/values.yml");
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
- 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
492
- # Close this file when your changes are ready, then they are pushed.
493
- # To cancel the edit, press Ctrl+C in the terminal and then close this file.
494
- # \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
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)));