@jondotsoy/envs 0.1.9-alpha.2 → 0.1.9-alpha.3

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 CHANGED
@@ -43,7 +43,7 @@ A walkthrough with a screenshot of each action is in [docs/ui.md](docs/ui.md) (i
43
43
  | Command | What it does |
44
44
  | --- | --- |
45
45
  | `envs edit` | Runs `init` if `.envs/values.yml` does not exist yet, then runs `pull`, opens `values.yml` with `code -w` and, when the editor closes, runs `push`. If the editor fails, it does not `push`. |
46
- | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Its **History** panel lists the history points (newest first); hovering or clicking a point shows what restoring it would change in the editor (red: removed, green: changed or added), and its **Restore** button, after a confirmation, runs `envs restore` for that point. Open the address it prints (it carries a one-time access token); requests without the token, from another host name or from another site are refused (see [docs/ui.md](docs/ui.md)). Stop the server with Ctrl+C. |
46
+ | `envs edit --ui` | Same `init` and `pull`, but instead of VS Code it starts a local server (`127.0.0.1`, random port) and opens a React web editor with the values of `values.yml` (worktree profiles, defaults and per-worktree values). The **Save** button in the top right corner writes `values.yml` and runs `push`. Its **History** panel lists the history points (newest first); hovering or clicking a point shows what restoring it would change in the editor (red: removed, green: changed or added), and its **Restore** button, after a confirmation, runs `envs restore` for that point. Open the address it prints (it carries a single-use access token: the first visit trades it for a session cookie, and using it prints a fresh single-use address; if the browser cannot be opened, the address is printed to open by hand; the token never appears in `ps`); requests without the token, from another host name or from another site are refused (see [docs/ui.md](docs/ui.md)). Stop the server with Ctrl+C. |
47
47
  | `envs init` | Creates `.envs/`, `.envs/.gitignore` (containing `*`) and an empty `.envs/values.yml`. Does not overwrite existing files. |
48
48
  | `envs pull` | Reads the `.env` of the main repo and of each worktree and writes it to `values.yml`. Variables that are no longer in a `.env` are removed from `values.yml` for that worktree. |
49
49
  | `envs push` | Writes `values.yml` to the `.env` of each worktree. Updates existing variables in place, appends new ones at the end, keeps comments, refuses (writing nothing) a variable name that is not valid or a value with a line break, and removes the orphan variables: those `envs` manages (they are, or were, in `values.yml` or its history) but that the worktree no longer resolves, for example after `envs restore`. Variables that `envs` never saw are left alone. |
package/SECURITY.md CHANGED
@@ -28,7 +28,7 @@ I will reply as soon as possible and we will coordinate the fix and the disclosu
28
28
  | `envs init` | — | Creates `<root>/.envs/`, `<root>/.envs/.gitignore` and `<root>/.envs/values.yml`, only if they do not exist. Never overwrites. |
29
29
  | `envs pull` | `<worktree>/.env` of each worktree | Rewrites `<root>/.envs/values.yml`. The file is serialized again, so YAML comments are lost. |
30
30
  | `envs push` | `<root>/.envs/values.yml` | Creates or updates `<worktree>/.env` of each worktree that has values. Updates existing variables in place, appends new ones at the end and keeps the rest of the file (it sets the mode of every `.env` it writes to `600`, so another user, such as a container mounting it, can no longer read it), except the orphan variables: those `envs` manages (they are or were in `values.yml` or its history) that the worktree no longer resolves are removed. If there are `defaults`, this includes all worktrees. |
31
- | `envs edit` | Same as `pull` and `push` | Same as `pull` and `push`. It also runs `code -w <root>/.envs/values.yml`. With `--ui` it serves the web editor instead (see below). |
31
+ | `envs edit` | Same as `pull` and `push` | Same as `pull` and `push`. It also runs `code -w <root>/.envs/values.yml`. With `--ui` it serves the web editor instead and runs the system opener (`xdg-open`, `open` or `explorer`) on a temporary redirect page under the OS temp directory (see below). |
32
32
  | `envs use`, `envs restore` | `<root>/.envs/values.yml`, `<root>/.envs/history.ndjson` | Rewrite `values.yml` and then act as `push`. |
33
33
  | `envs config` | `<root>/.envs/config.yml` | Creates or updates `<root>/.envs/config.yml` (mode `600`). |
34
34
 
@@ -51,7 +51,14 @@ Additionally:
51
51
 
52
52
  ## Web editor (`envs edit --ui`)
53
53
 
54
- The server listens on `127.0.0.1` only and exposes the values of every `.env`, secrets included, so each request must pass these checks: a random per-run access token (in the printed URL, then an `HttpOnly`, `SameSite=Strict` cookie), a `Host` of this server (no DNS rebinding), an `Origin` of this server, `application/json` bodies and a body of at most 5 MiB; it never answers CORS preflights. Open only the address printed by the command and do not share it: it carries the token. See [docs/ui.md](docs/ui.md).
54
+ The server listens on `127.0.0.1` only and exposes the values of every `.env`, secrets included, so each request must pass these checks: a random per-run access token (in the printed URL, **single-use**: the first visit trades it for a different session secret, set as an `HttpOnly`, `SameSite=Strict` cookie), a `Host` of this server (no DNS rebinding), an `Origin` of this server, `application/json` bodies and a body of at most 5 MiB; it never answers CORS preflights. Open only the address printed by the command and do not share it: it carries the token. See [docs/ui.md](docs/ui.md).
55
+
56
+ How the access token is handled:
57
+
58
+ - **Single-use.** Once the token in the URL is used it stops working (`401`), so a copy left in the browser history, the terminal or a screenshot is useless afterwards. Using it prints a fresh single-use URL to stdout (`New single-use URL: …`) to open the editor from another browser; every URL gives the same session. The session secret is never in a URL.
59
+ - **Not in `ps`.** The URL is not passed as an argument to `xdg-open`/`open`/`explorer`, where any user could read it in the process list. The opener receives a private temporary page (a directory made with `mkdtemp`, file mode `600`) that redirects to the URL. The page is removed as soon as the token is used, after 60 seconds at most, when the process exits, or when the opener fails.
60
+ - **If the browser cannot be opened** the command prints `Could not open the browser. Open this address manually: …`.
61
+ - **Remaining exposure.** The token is printed to stdout and is in the address bar until it is used, so anyone who can read your screen or your terminal output in that moment can use it before you do. If the address is opened by someone else first, your own visit gets a `401`: stop the server (Ctrl+C) and start it again.
55
62
 
56
63
  ## The history
57
64
 
package/envs.js CHANGED
@@ -499,7 +499,9 @@ function presentedToken(req) {
499
499
  }
500
500
  async function serveUi(ctx) {
501
501
  const assets = await loadAssets();
502
- const token = randomBytes(24).toString("base64url");
502
+ let opened;
503
+ let token = randomBytes(24).toString("base64url");
504
+ const session = randomBytes(24).toString("base64url");
503
505
  const guard = (handler) => async (req) => {
504
506
  const port = server.port;
505
507
  const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
@@ -512,15 +514,18 @@ async function serveUi(ctx) {
512
514
  }
513
515
  const url = new URL(req.url);
514
516
  if (url.pathname === "/" && sameToken(url.searchParams.get("token"), token)) {
517
+ token = randomBytes(24).toString("base64url");
518
+ ctx.log(`New single-use URL: ${urlWith(token)}`);
519
+ opened?.then((cleanup) => cleanup?.(), () => {});
515
520
  return new Response(null, {
516
521
  status: 302,
517
522
  headers: {
518
523
  location: "/",
519
- "set-cookie": `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`
524
+ "set-cookie": `${TOKEN_COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`
520
525
  }
521
526
  });
522
527
  }
523
- if (!sameToken(presentedToken(req), token)) {
528
+ if (!sameToken(presentedToken(req), session)) {
524
529
  return new Response("Unauthorized: open the address printed by `envs edit --ui`.", { status: 401 });
525
530
  }
526
531
  if (req.method !== "GET" && req.method !== "HEAD" && !req.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
@@ -574,9 +579,15 @@ async function serveUi(ctx) {
574
579
  return asset ? new Response(asset) : new Response("Not found", { status: 404 });
575
580
  })
576
581
  });
577
- const url = `http://127.0.0.1:${server.port}/?token=${token}`;
582
+ const urlWith = (t) => `http://127.0.0.1:${server.port}/?token=${t}`;
583
+ const url = urlWith(token);
578
584
  ctx.log(`Web editor running at ${url} (Ctrl+C to stop)`);
579
- await ctx.openUrl?.(url);
585
+ try {
586
+ opened = ctx.openUrl?.(url);
587
+ await opened;
588
+ } catch {
589
+ ctx.log(`Could not open the browser. Open this address manually: ${url}`);
590
+ }
580
591
  await (ctx.waitForExit ?? (() => new Promise(() => {})))();
581
592
  await server.stop(true);
582
593
  return 0;
@@ -593,6 +604,8 @@ import { AsyncLocalStorage } from "async_hooks";
593
604
  import { chmod, mkdir, open, rename, unlink, writeFile } from "fs/promises";
594
605
  import { basename, dirname, join as join2 } from "path";
595
606
  import { stat } from "fs/promises";
607
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
608
+ import { tmpdir } from "os";
596
609
  import { createHash, createHmac, randomBytes as randomBytes2 } from "crypto";
597
610
  import { appendFile, truncate } from "fs/promises";
598
611
  var {$ } = globalThis.Bun;
@@ -1567,9 +1580,21 @@ Commands:
1567
1580
  },
1568
1581
  openUrl: async (url) => {
1569
1582
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
1583
+ const dir = mkdtempSync(join2(tmpdir(), "envs-"));
1584
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
1570
1585
  try {
1571
- await Bun.spawn([opener, url], { stdio: ["ignore", "ignore", "ignore"] }).exited;
1572
- } catch {}
1586
+ const page = join2(dir, "open.html");
1587
+ writeFileSync(page, `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${url}"><script>location.replace(${JSON.stringify(url)})</script>`, { mode: 384 });
1588
+ process.once("exit", cleanup);
1589
+ setTimeout(cleanup, 60000).unref();
1590
+ const code = await Bun.spawn([opener, page], { stdio: ["ignore", "ignore", "ignore"] }).exited;
1591
+ if (code !== 0)
1592
+ throw new Error(`${opener} exited with code ${code}`);
1593
+ return cleanup;
1594
+ } catch (error) {
1595
+ cleanup();
1596
+ throw error;
1597
+ }
1573
1598
  },
1574
1599
  waitForExit: () => new Promise((resolve) => {
1575
1600
  process.once("SIGINT", () => resolve());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jondotsoy/envs",
3
- "version": "0.1.9-alpha.2",
3
+ "version": "0.1.9-alpha.3",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Jonathan Delgado",