getmyenv 0.8.0 → 0.9.0

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
@@ -65,7 +65,7 @@ Server tokens are read-only. `set` needs the folder token from browser sign-in,
65
65
  ## Open and Read-only
66
66
 
67
67
  - Open contexts: the CLI can read and write. Browser sign-in gives the folder a token in `.getmyenv/token.json`.
68
- - Read-only contexts: the CLI can only read. Set values in the dashboard. Use a server token from the CLI page (one context, IP allowlist) with `GETMYENV_TOKEN`. Each server token has a new machines rule: ask you on Requests (default), trust machines from its allowed IPs on first read (each entry /24 or narrower, IPv6 /64), or block them. Trusted machines read without asking. `status` shows the rule.
68
+ - Read-only contexts: the CLI can read and export, never write. Set values in the dashboard. Use a server token from Project settings, Tokens (one context, IP allowlist) with `GETMYENV_TOKEN`. Each server token has a new machines rule: ask you on Requests (default), trust machines from its allowed IPs on first read (each entry /24 or narrower, IPv6 /64), or block them. Trusted machines read without asking. `status` shows the rule.
69
69
 
70
70
  ## Chaining
71
71
 
@@ -5,5 +5,9 @@ export type ExportOpts = {
5
5
  stdout?: boolean;
6
6
  yes?: boolean;
7
7
  };
8
- /** `export`: write one open context to a plaintext file, for tools that need one. */
8
+ /**
9
+ * `export`: write one context to a plaintext file, for tools that need one.
10
+ * Read-only contexts go through the same checks as `run`: server token,
11
+ * allowed IP, and a trusted or approved machine.
12
+ */
9
13
  export declare function exportCommand(opts: ExportOpts): Promise<void>;
@@ -6,18 +6,22 @@ import { ensureGitignoreHas, ensureGitignoreHasEnv, parseEnvFile, writePrivateFi
6
6
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
7
7
  import { resolveContext } from "../lib/context.js";
8
8
  import { confirmDiff } from "../lib/confirm.js";
9
+ import { resolveValues } from "../lib/resolve.js";
9
10
  import { contextKey } from "../lib/vault.js";
10
11
  import { assertVariableNames } from "../lib/run-env.js";
11
- /** `export`: write one open context to a plaintext file, for tools that need one. */
12
+ /**
13
+ * `export`: write one context to a plaintext file, for tools that need one.
14
+ * Read-only contexts go through the same checks as `run`: server token,
15
+ * allowed IP, and a trusted or approved machine.
16
+ */
12
17
  export async function exportCommand(opts) {
13
18
  const log = opts.stdout ? (l = "") => console.error(l) : (l = "") => console.log(l);
14
19
  const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl, quiet: opts.stdout });
15
20
  const ctx = resolveContext(linked.who, linked.meta, opts.context);
16
- if (ctx.protection === "locked") {
17
- throw new Error(`${ctx.slug} is read-only. Export it in the dashboard.`);
18
- }
19
21
  log(pc.yellow(EXPORT_WARNING));
20
- const data = await linked.client.request("GET", `/api/cli/values?context=${encodeURIComponent(ctx.slug)}`);
22
+ const data = ctx.protection === "locked"
23
+ ? await resolveValues(linked.client, linked.who.project.id, ctx.slug, "export")
24
+ : await linked.client.request("GET", `/api/cli/values?context=${encodeURIComponent(ctx.slug)}`);
21
25
  assertVariableNames([...data.values.map((v) => v.name), ...data.missing.map((m) => m.name)]);
22
26
  const remote = {};
23
27
  if (data.values.length > 0) {
@@ -2,40 +2,13 @@ import { spawn } from "node:child_process";
2
2
  import os from "node:os";
3
3
  import pc from "picocolors";
4
4
  import { decryptSecret, parsePayload } from "@getmyenv/crypto";
5
- import { ApiError } from "../lib/api.js";
6
5
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
7
6
  import { resolveContext } from "../lib/context.js";
8
- import { signMachineProof } from "../lib/identity.js";
7
+ import { resolveValues } from "../lib/resolve.js";
9
8
  import { contextKey } from "../lib/vault.js";
10
- import { sleep } from "../lib/authorize.js";
11
9
  import { assertVariableNames, explainLines, mergeEnv, signalExitCode, validationError, windowsCommandLine, } from "../lib/run-env.js";
12
- const GRANT_HEADER = "X-Getmyenv-Grant";
13
10
  /** Status lines go to stderr so the child's stdout stays clean for pipes. */
14
11
  const log = (line = "") => console.error(line);
15
- async function resolveOnce(client, projectId, slug, grant) {
16
- return client.request("POST", "/api/cli/resolve", { context: slug, machine: signMachineProof(projectId, slug) }, grant ? { [GRANT_HEADER]: grant } : undefined);
17
- }
18
- async function waitForGrant(client, pending) {
19
- log(`Waiting for approval in the dashboard: ${pending.reviewUrl}`);
20
- log(pc.dim("Trust this machine there to skip approval next time.\n"));
21
- const deadline = new Date(pending.expiresAt).getTime();
22
- while (Date.now() < deadline) {
23
- await sleep(1500);
24
- const s = await client.request("GET", `/api/cli/requests/${pending.requestId}`);
25
- if (s.status === "approved" && s.grantToken) {
26
- log(`${pc.green("✓")} Approved\n`);
27
- return s.grantToken;
28
- }
29
- if (s.status === "approved") {
30
- throw new Error("This approval was already used. Run the command again.");
31
- }
32
- if (s.status === "denied")
33
- throw new Error("Access denied in the dashboard.");
34
- if (s.status === "expired")
35
- break;
36
- }
37
- throw new Error("Approval request expired. Run the command again.");
38
- }
39
12
  /** Resolve, validate, merge, spawn. */
40
13
  export async function runCommand(opts, childArgs) {
41
14
  if (childArgs.length === 0 && !opts.explain) {
@@ -44,24 +17,7 @@ export async function runCommand(opts, childArgs) {
44
17
  const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
45
18
  const { who, client, meta } = linked;
46
19
  const ctx = resolveContext(who, meta, opts.context);
47
- let res = await resolveOnce(client, who.project.id, ctx.slug);
48
- if (res.status === "pending") {
49
- const grant = await waitForGrant(client, res);
50
- try {
51
- res = await resolveOnce(client, who.project.id, ctx.slug, grant);
52
- }
53
- catch (err) {
54
- if (err instanceof ApiError && err.status === 401) {
55
- throw new Error(`${err.message}. Run the command again.`);
56
- }
57
- throw err;
58
- }
59
- if (res.status !== "ok")
60
- throw new Error("Approval did not complete. Run the command again.");
61
- }
62
- if (res.via === "auto") {
63
- log(`${pc.green("✓")} This machine is now trusted for ${res.context.slug}.`);
64
- }
20
+ const res = await resolveValues(client, who.project.id, ctx.slug, "run");
65
21
  assertVariableNames([...res.values.map((v) => v.name), ...res.missing.map((m) => m.name)]);
66
22
  const values = {};
67
23
  if (res.values.length > 0) {
@@ -37,7 +37,7 @@ export async function unlinkCommand(opts) {
37
37
  // 401/404: the token is already gone on the server. Anything else keeps
38
38
  // token.json, so a still-valid token is never left without a local copy.
39
39
  if (!(err instanceof ApiError && (err.status === 401 || err.status === 404))) {
40
- throw new Error("Could not revoke the token on the server. Nothing was removed. Try again, or revoke it on the CLI page first.");
40
+ throw new Error("Could not revoke the token on the server. Nothing was removed. Try again, or revoke it in Project settings, Tokens first.");
41
41
  }
42
42
  console.log(pc.dim("Token was already revoked."));
43
43
  }
@@ -60,8 +60,8 @@ async function saveVerifiedToken(token, apiUrl) {
60
60
  return who;
61
61
  }
62
62
  async function authorizeViaPaste(apiUrl) {
63
- const cliPage = `${apiUrl.replace(/\/$/, "")}/cli`;
64
- console.error(pc.dim(`Create or copy a folder token: ${cliPage}\n`));
63
+ const projectsPage = `${apiUrl.replace(/\/$/, "")}/projects`;
64
+ console.error(pc.dim(`Create a folder token in Project settings, Tokens: ${projectsPage}\n`));
65
65
  const token = (await readLine("Paste the folder token: ")).trim();
66
66
  if (!token) {
67
67
  throw new Error("No token entered.");
@@ -34,7 +34,7 @@ export async function fetchWhoami(client) {
34
34
  }
35
35
  if (err instanceof ApiError && err.status === 401) {
36
36
  throw new Error(process.env.GETMYENV_TOKEN?.trim()
37
- ? `${err.message}\nGETMYENV_TOKEN no longer works. Create a new token on the CLI page.`
37
+ ? `${err.message}\nGETMYENV_TOKEN no longer works. Create a new token in Project settings, Tokens.`
38
38
  : `${err.message}\nThis folder's token no longer works. Run: npx getmyenv unlink, then npx getmyenv`);
39
39
  }
40
40
  throw err;
@@ -23,7 +23,7 @@ export function assertTokenNotCommitted(cwd = process.cwd()) {
23
23
  if (fs.existsSync(file) && isTrackedByGit(file, cwd)) {
24
24
  throw new Error([
25
25
  ".getmyenv/token.json is committed to git. Anyone with the repo can use it.",
26
- "Revoke it on the CLI page, then: git rm --cached .getmyenv/token.json",
26
+ "Revoke it in Project settings, Tokens, then: git rm --cached .getmyenv/token.json",
27
27
  "Then run: npx getmyenv unlink, and npx getmyenv",
28
28
  ].join("\n"));
29
29
  }
@@ -0,0 +1,25 @@
1
+ import type { SealedKey } from "@getmyenv/crypto";
2
+ import { ApiClient, type Protection } from "./api.js";
3
+ import type { MissingKey } from "./run-env.js";
4
+ export type ResolveOk = {
5
+ status: "ok";
6
+ /** How the read was allowed. */
7
+ via: "open" | "trusted" | "auto" | "grant";
8
+ context: {
9
+ id: number;
10
+ slug: string;
11
+ name: string;
12
+ protection: Protection;
13
+ };
14
+ sealedKey: SealedKey | null;
15
+ values: Array<{
16
+ name: string;
17
+ ciphertext: string;
18
+ }>;
19
+ missing: MissingKey[];
20
+ };
21
+ /**
22
+ * Ciphertext for one context. Read-only contexts need a server token, an
23
+ * allowed IP, and a trusted machine or an approval in the dashboard.
24
+ */
25
+ export declare function resolveValues(client: ApiClient, projectId: string, slug: string, purpose: "run" | "export"): Promise<ResolveOk>;
@@ -0,0 +1,56 @@
1
+ import pc from "picocolors";
2
+ import { ApiError } from "./api.js";
3
+ import { sleep } from "./authorize.js";
4
+ import { signMachineProof } from "./identity.js";
5
+ const GRANT_HEADER = "X-Getmyenv-Grant";
6
+ /** Status lines go to stderr so stdout stays clean for pipes. */
7
+ const log = (line = "") => console.error(line);
8
+ async function resolveOnce(client, projectId, slug, purpose, grant) {
9
+ return client.request("POST", "/api/cli/resolve", { context: slug, machine: signMachineProof(projectId, slug), purpose }, grant ? { [GRANT_HEADER]: grant } : undefined);
10
+ }
11
+ async function waitForGrant(client, pending) {
12
+ log(`Waiting for approval in the dashboard: ${pending.reviewUrl}`);
13
+ log(pc.dim("Trust this machine there to skip approval next time.\n"));
14
+ const deadline = new Date(pending.expiresAt).getTime();
15
+ while (Date.now() < deadline) {
16
+ await sleep(1500);
17
+ const s = await client.request("GET", `/api/cli/requests/${pending.requestId}`);
18
+ if (s.status === "approved" && s.grantToken) {
19
+ log(`${pc.green("✓")} Approved\n`);
20
+ return s.grantToken;
21
+ }
22
+ if (s.status === "approved") {
23
+ throw new Error("This approval was already used. Run the command again.");
24
+ }
25
+ if (s.status === "denied")
26
+ throw new Error("Access denied in the dashboard.");
27
+ if (s.status === "expired")
28
+ break;
29
+ }
30
+ throw new Error("Approval request expired. Run the command again.");
31
+ }
32
+ /**
33
+ * Ciphertext for one context. Read-only contexts need a server token, an
34
+ * allowed IP, and a trusted machine or an approval in the dashboard.
35
+ */
36
+ export async function resolveValues(client, projectId, slug, purpose) {
37
+ let res = await resolveOnce(client, projectId, slug, purpose);
38
+ if (res.status === "pending") {
39
+ const grant = await waitForGrant(client, res);
40
+ try {
41
+ res = await resolveOnce(client, projectId, slug, purpose, grant);
42
+ }
43
+ catch (err) {
44
+ if (err instanceof ApiError && err.status === 401) {
45
+ throw new Error(`${err.message}. Run the command again.`);
46
+ }
47
+ throw err;
48
+ }
49
+ if (res.status !== "ok")
50
+ throw new Error("Approval did not complete. Run the command again.");
51
+ }
52
+ if (res.via === "auto") {
53
+ log(`${pc.green("✓")} This machine is now trusted for ${res.context.slug}.`);
54
+ }
55
+ return res;
56
+ }
package/dist/lib/vault.js CHANGED
@@ -43,7 +43,7 @@ export async function contextKey(linked, target, opts = {}) {
43
43
  const key = opened.get(target.id);
44
44
  if (!key) {
45
45
  throw new Error(member
46
- ? `The owner has not sent you the key for ${target.slug} yet. Ask them to open Members in the dashboard.`
46
+ ? `The owner has not sent you the key for ${target.slug} yet. Ask them to open Project settings, Members in the dashboard.`
47
47
  : `No key for ${target.slug} on this account. Open the project in the dashboard once.`);
48
48
  }
49
49
  for (const [id, k] of opened)
@@ -59,7 +59,7 @@ export async function contextKey(linked, target, opts = {}) {
59
59
  async function serverContextKey(linked, target) {
60
60
  const { privateKey } = splitServerToken(process.env.GETMYENV_TOKEN?.trim() ?? "");
61
61
  if (!privateKey) {
62
- throw new Error("This server token has no key. Create a new server token on the CLI page.");
62
+ throw new Error("This server token has no key. Create a new server token in Project settings, Tokens.");
63
63
  }
64
64
  try {
65
65
  let sealed = target.sealedKey;
@@ -67,7 +67,7 @@ async function serverContextKey(linked, target) {
67
67
  sealed = (await fetchVault(linked)).contexts.find((c) => c.id === target.id)?.sealedKey ?? null;
68
68
  }
69
69
  if (!sealed) {
70
- throw new Error(`This server token has no key for ${target.slug}. Create a new server token on the CLI page.`);
70
+ throw new Error(`This server token has no key for ${target.slug}. Create a new server token in Project settings, Tokens.`);
71
71
  }
72
72
  const key = openSealedKey(sealed, privateKey, target.id);
73
73
  memo.set(target.id, key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getmyenv",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "CLI for getmyenv, an encrypted store for environment variables and secrets. We store ciphertext only.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,15 +34,15 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@getmyenv/crypto": "0.4.0",
37
- "@getmyenv/shared": "0.5.0",
37
+ "@getmyenv/shared": "0.6.0",
38
38
  "@napi-rs/keyring": "^2.1.0",
39
- "commander": "^13.1.0",
39
+ "commander": "^15.0.0",
40
40
  "dotenv": "^16.4.7",
41
41
  "picocolors": "^1.1.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^22.13.10",
45
- "typescript": "^5.8.2",
45
+ "typescript": "^7.0.2",
46
46
  "vitest": "^4.1.11"
47
47
  },
48
48
  "engines": {