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 +1 -1
- package/dist/commands/export.d.ts +5 -1
- package/dist/commands/export.js +9 -5
- package/dist/commands/run.js +2 -46
- package/dist/commands/unlink.js +1 -1
- package/dist/lib/authorize.js +2 -2
- package/dist/lib/bootstrap.js +1 -1
- package/dist/lib/credentials.js +1 -1
- package/dist/lib/resolve.d.ts +25 -0
- package/dist/lib/resolve.js +56 -0
- package/dist/lib/vault.js +3 -3
- package/package.json +4 -4
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
|
|
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
|
-
/**
|
|
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>;
|
package/dist/commands/export.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
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) {
|
package/dist/commands/run.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) {
|
package/dist/commands/unlink.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/lib/authorize.js
CHANGED
|
@@ -60,8 +60,8 @@ async function saveVerifiedToken(token, apiUrl) {
|
|
|
60
60
|
return who;
|
|
61
61
|
}
|
|
62
62
|
async function authorizeViaPaste(apiUrl) {
|
|
63
|
-
const
|
|
64
|
-
console.error(pc.dim(`Create
|
|
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.");
|
package/dist/lib/bootstrap.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/lib/credentials.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
37
|
+
"@getmyenv/shared": "0.6.0",
|
|
38
38
|
"@napi-rs/keyring": "^2.1.0",
|
|
39
|
-
"commander": "^
|
|
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": "^
|
|
45
|
+
"typescript": "^7.0.2",
|
|
46
46
|
"vitest": "^4.1.11"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|