@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/envs.js CHANGED
@@ -1,40 +1,18 @@
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
  }
@@ -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 rules = [
85
- {
86
- id: "tracked-values",
87
- description: "values.yml is tracked by git.",
88
- async check(ctx) {
89
- if (!await ctx.isTracked(ctx.root, valuesFile))
90
- return [];
91
- return [
92
- {
93
- location: valuesFile,
94
- message: "values.yml is tracked by git, so its secrets are in the history. Run `git rm --cached .envs/values.yml`."
95
- }
96
- ];
97
- }
98
- },
99
- {
100
- id: "unignored-values",
101
- description: "values.yml is neither tracked nor ignored by git.",
102
- async check(ctx) {
103
- if (await ctx.isTracked(ctx.root, valuesFile))
104
- return [];
105
- if (await ctx.isIgnored(ctx.root, valuesFile))
106
- return [];
107
- return [
108
- {
109
- location: valuesFile,
110
- message: "values.yml is not ignored by git and could be committed by accident. Add `*` to .envs/.gitignore."
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
- id: "open-permissions",
117
- description: "values.yml is readable by other users.",
118
- check({ valuesMode }) {
119
- if (valuesMode === undefined || (valuesMode & 63) === 0)
120
- return [];
121
- return [
122
- {
123
- location: valuesFile,
124
- message: `values.yml is readable by other users (mode ${(valuesMode & 511).toString(8)}). Run \`chmod 600 .envs/values.yml\`.`
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
- return found;
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
- id: "unignored-dotenv",
163
- description: "A worktree's .env is neither tracked nor ignored by git.",
164
- async check(ctx) {
165
- const found = [];
166
- for (const { name, path } of ctx.dotenvs) {
167
- if (await ctx.isTracked(path, ".env"))
168
- continue;
169
- if (await ctx.isIgnored(path, ".env"))
170
- continue;
171
- found.push({
172
- location: `${name}/.env`,
173
- message: "The .env is not ignored by git. Add `.env` to .gitignore."
174
- });
175
- }
176
- return found;
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
- // src/envs.ts
236
- var HELP = `Usage: envs <command>
237
-
238
- Commands:
239
- init Create the .envs/ directory (.gitignore, values.yml)
240
- pull Read each worktree's .env and write it into .envs/values.yml
241
- push Write defaults and .envs/values.yml into the .env of each worktree
242
- lint Warn about insecure values and unprotected secret files
243
- edit Pull, open .envs/values.yml in VS Code (code -w), then push
244
- use Select the profile of the current worktree (envs use <profile>), or list them
245
- help Show this help message
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
- var DEFAULT_PROFILE = "default";
266
- var hasProfile = (values, name) => Object.hasOwn(values.profiles, name);
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 = join(await findRoot(ctx.cwd), ".envs/values.yml");
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(join(root, ".envs"), { recursive: true });
478
+ await mkdir(join2(root, ".envs"), { recursive: true });
422
479
  for (const [name, content] of Object.entries(files)) {
423
- const path = join(root, name);
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(join(path, ".env"));
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(join(path, ".env"));
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 = join(worktree.path, ".env");
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 = 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");
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
- var EDIT_NOTICE = `# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
606
- # Close this file when your changes are ready, then they are pushed.
607
- # To cancel the edit, press Ctrl+C in the terminal and then close this file.
608
- # \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
609
- `;
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)));