getmyenv 0.12.2 → 0.14.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 +25 -12
- package/dist/bin.js +8 -0
- package/dist/cli.js +13 -3
- package/dist/commands/backup.js +1 -1
- package/dist/commands/claim.js +3 -2
- package/dist/commands/context.js +4 -4
- package/dist/commands/export.js +19 -22
- package/dist/commands/guest.d.ts +1 -1
- package/dist/commands/guest.js +17 -6
- package/dist/commands/import.js +42 -10
- package/dist/commands/lock.js +1 -1
- package/dist/commands/ls.js +2 -2
- package/dist/commands/push.d.ts +25 -0
- package/dist/commands/push.js +189 -0
- package/dist/commands/restore.js +3 -3
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +63 -24
- package/dist/commands/set.d.ts +16 -0
- package/dist/commands/set.js +98 -14
- package/dist/commands/start.js +7 -7
- package/dist/commands/status.js +3 -3
- package/dist/commands/template.js +9 -6
- package/dist/commands/unlink.d.ts +14 -5
- package/dist/commands/unlink.js +79 -17
- package/dist/lib/api-url.d.ts +2 -0
- package/dist/lib/api-url.js +10 -0
- package/dist/lib/api.d.ts +22 -2
- package/dist/lib/api.js +108 -34
- package/dist/lib/authorize.js +2 -2
- package/dist/lib/bootstrap.d.ts +5 -0
- package/dist/lib/bootstrap.js +32 -8
- package/dist/lib/colors.d.ts +14 -0
- package/dist/lib/colors.js +17 -0
- package/dist/lib/confirm.d.ts +7 -3
- package/dist/lib/confirm.js +21 -12
- package/dist/lib/context.js +1 -1
- package/dist/lib/credentials.d.ts +4 -0
- package/dist/lib/credentials.js +13 -11
- package/dist/lib/fs.d.ts +36 -1
- package/dist/lib/fs.js +102 -23
- package/dist/lib/known-keys-cache.d.ts +15 -0
- package/dist/lib/known-keys-cache.js +102 -0
- package/dist/lib/known-keys.d.ts +2 -0
- package/dist/lib/known-keys.js +12 -3
- package/dist/lib/read-context.d.ts +26 -0
- package/dist/lib/read-context.js +22 -0
- package/dist/lib/recovery-code.js +1 -1
- package/dist/lib/resolve.d.ts +2 -1
- package/dist/lib/resolve.js +1 -1
- package/dist/lib/run-env.d.ts +19 -3
- package/dist/lib/run-env.js +66 -13
- package/dist/lib/template-file.js +1 -1
- package/dist/lib/update-check.js +1 -1
- package/dist/lib/upload.js +2 -2
- package/dist/lib/vault.js +1 -1
- package/dist/providers/http.d.ts +16 -0
- package/dist/providers/http.js +69 -0
- package/dist/providers/netlify.d.ts +11 -0
- package/dist/providers/netlify.js +184 -0
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/vercel.d.ts +17 -0
- package/dist/providers/vercel.js +205 -0
- package/package.json +19 -12
package/README.md
CHANGED
|
@@ -17,19 +17,19 @@ npx getmyenv run staging -- npm start # or pick one
|
|
|
17
17
|
|
|
18
18
|
## Commands
|
|
19
19
|
|
|
20
|
-
- `getmyenv`: setup in a new folder, status in a linked one. Without a TTY it prints next steps and exits.
|
|
21
|
-
- `run [context] -- <cmd>`: resolve, validate, merge, spawn. A missing value stops the run and prints its name (`--allow-missing` to run anyway). getmyenv values replace the parent env and replaced names are printed (`--keep-existing` to keep parent values). `--explain` lists names and origins, never values.
|
|
20
|
+
- `getmyenv`: setup in a new folder, status in a linked one. Without a TTY in an unlinked folder it prints next steps and exits with code 1.
|
|
21
|
+
- `run [context] -- <cmd>`: resolve, validate, merge, spawn. A missing value stops the run and prints its name (`--allow-missing` to run anyway). Context values for names that change how programs start, like `NODE_OPTIONS`, `PATH`, `LD_PRELOAD`, `DYLD_*`, `GIT_*`, `NPM_CONFIG_*`, `HTTPS_PROXY` or `BASH_ENV`, stop the run (`--allow-unsafe-env` to run anyway). The list is best effort. `GETMYENV_*` names from a context are never passed, and `set` and `import` refuse them. getmyenv values replace the parent env and replaced names are printed (`--keep-existing` to keep parent values). `--explain` lists names and origins, never values.
|
|
22
22
|
- `status`: project, token, contexts and the run command.
|
|
23
23
|
- `context list | create "<name>" [--from <slug>] [--use] | use <slug>`
|
|
24
|
-
- `set KEY[=VALUE]... [--expires <date|never>]`, `set KEY --stdin`, `unset KEY... [--all]`, `ls`
|
|
24
|
+
- `set KEY[=VALUE]... [--expires <date|never>]`, `set KEY --stdin`, `set --missing` (asks, hidden, for every name with no value), `unset KEY... [--all]`, `ls`. Names are uppercase letters, numbers and underscores, like `API_KEY`. `set KEY=` does not clear a value. Use `unset`.
|
|
25
25
|
- `import [file] [--prune] [--dry-run] [--yes]`: `.env.<slug>` goes to that context.
|
|
26
26
|
- `export [-o file | --stdout] [--yes]`: for tools that need a file. The file is added to `.gitignore` and gets owner-only permissions on macOS and Linux.
|
|
27
|
-
- `guest [--name <name>]`: a project without an account. It
|
|
27
|
+
- `guest [--name <name>]`: a project without an account. It stays active while the CLI uses it. After 30 days without use, or 180 days after creation, values can't change. For 30 more days, `run` still works and it can still be claimed.
|
|
28
28
|
- `claim`: claim a guest project. Reads `.getmyenv/claim.json`, or asks for the claim code and guest passphrase that `guest` printed once at a terminal.
|
|
29
29
|
- `backup [-o file]`: encrypted backup of open contexts (default `<project>.getmyenv-backup`). It opens with the Vault password or the recovery code in effect when it was made.
|
|
30
30
|
- `restore <file> [--dry-run] [--yes]`: restore a backup into this folder's project. Values go into open contexts with the same slug and replace values with the same name. Read-only and missing contexts are skipped.
|
|
31
31
|
- `start`: same as bare `getmyenv`. `start --template <id or link>` adds a template's variable names and asks for each value.
|
|
32
|
-
- `lock`: forget every unlocked context key on this machine. `unlink [--yes]
|
|
32
|
+
- `lock`: forget every unlocked context key on this machine. `unlink [--yes] [--local]` (`--local` removes the local files without revoking the token), `help [command]`
|
|
33
33
|
|
|
34
34
|
`run`, `set`, `unset`, `ls`, `import` and `export` take `-c, --context <slug>`. The default is the server token's context, then `context` in `.getmyenv/project.json`, then `development`. Every command takes `--api-url <url>`.
|
|
35
35
|
|
|
@@ -49,7 +49,7 @@ my-app/
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
<!-- cli:help -->
|
|
52
|
-
Output of `npx getmyenv --help` (0.
|
|
52
|
+
Output of `npx getmyenv --help` (0.14.0):
|
|
53
53
|
|
|
54
54
|
```text
|
|
55
55
|
Usage: getmyenv [options] [command]
|
|
@@ -57,9 +57,9 @@ Usage: getmyenv [options] [command]
|
|
|
57
57
|
Keep using environment variables. Stop managing them as files.
|
|
58
58
|
|
|
59
59
|
Options:
|
|
60
|
-
-V, --version
|
|
60
|
+
-V, --version Print the version
|
|
61
61
|
--api-url <url> API base URL
|
|
62
|
-
-h, --help
|
|
62
|
+
-h, --help Show help
|
|
63
63
|
|
|
64
64
|
Commands:
|
|
65
65
|
start [options] Set up this folder (same as bare npx getmyenv).
|
|
@@ -68,8 +68,8 @@ Commands:
|
|
|
68
68
|
environment
|
|
69
69
|
status [options] Show project, token, contexts and the run command
|
|
70
70
|
context List, create or pick contexts
|
|
71
|
-
set [options]
|
|
72
|
-
|
|
71
|
+
set [options] [pairs...] Set values: set KEY=VALUE, set KEY to be prompted,
|
|
72
|
+
or set --missing
|
|
73
73
|
unset [options] <names...> Remove values from a context
|
|
74
74
|
ls [options] Variable names by context with status (never
|
|
75
75
|
values)
|
|
@@ -77,8 +77,11 @@ Commands:
|
|
|
77
77
|
goes to that context)
|
|
78
78
|
export [options] Write a context to a plaintext .env file, for
|
|
79
79
|
tools that need one
|
|
80
|
+
push [options] <provider> Copy a context to Vercel or Netlify with your
|
|
81
|
+
platform token (VERCEL_TOKEN, NETLIFY_AUTH_TOKEN)
|
|
80
82
|
guest [options] Create a guest project without an account. It
|
|
81
|
-
|
|
83
|
+
stays active while you use it, up to 180 days.
|
|
84
|
+
Claim it to keep it.
|
|
82
85
|
claim [options] Claim a guest project (reads .getmyenv/claim.json,
|
|
83
86
|
or asks for the claim code)
|
|
84
87
|
backup [options] Write an encrypted backup of open contexts
|
|
@@ -88,7 +91,7 @@ Commands:
|
|
|
88
91
|
(OS keychain)
|
|
89
92
|
unlink [options] Revoke this folder's token and remove local
|
|
90
93
|
.getmyenv files
|
|
91
|
-
help [command]
|
|
94
|
+
help [command] Show help for a command (same as <command> --help)
|
|
92
95
|
|
|
93
96
|
Environment:
|
|
94
97
|
GETMYENV_TOKEN Token for CI and servers (instead of .getmyenv/token.json). A server token holds its context key.
|
|
@@ -97,6 +100,7 @@ Environment:
|
|
|
97
100
|
GETMYENV_SKIP_UPDATE Set to 1 to skip the update notice
|
|
98
101
|
GETMYENV_KEYCHAIN Set to 0 to always ask for the Vault password
|
|
99
102
|
GETMYENV_MACHINE_LABEL Name shown for this machine in access requests
|
|
103
|
+
GETMYENV_PUSH_API_URL Vercel or Netlify API base URL, for tests (https, or http://localhost)
|
|
100
104
|
```
|
|
101
105
|
<!-- /cli:help -->
|
|
102
106
|
|
|
@@ -118,6 +122,15 @@ export GETMYENV_TOKEN=... # server token from the dashboard
|
|
|
118
122
|
npx getmyenv run -- node server.js
|
|
119
123
|
```
|
|
120
124
|
|
|
125
|
+
Windows PowerShell:
|
|
126
|
+
|
|
127
|
+
```powershell
|
|
128
|
+
$env:GETMYENV_TOKEN="..."
|
|
129
|
+
npx getmyenv run -- node server.js
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Check it works: `npx getmyenv run --explain` lists each name and where its value comes from. Never values.
|
|
133
|
+
|
|
121
134
|
Server tokens are read-only. `set` needs the folder token from browser sign-in, or a guest project. `printf %s "$VALUE" | npx getmyenv set API_KEY --stdin` keeps the value out of argv.
|
|
122
135
|
|
|
123
136
|
- `GETMYENV_TOKEN`: a server token from Project settings, Tokens.
|
package/dist/bin.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { formatCliError } from "@getmyenv/shared";
|
|
3
3
|
import { runCli } from "./cli.js";
|
|
4
|
+
import { ApiError } from "./lib/api.js";
|
|
4
5
|
import { maybeSuggestUpdate } from "./lib/update-check.js";
|
|
6
|
+
/** After a timeout, how long the event loop may take to drain before we force the exit. */
|
|
7
|
+
const EXIT_GRACE_MS = 1_000;
|
|
5
8
|
async function main() {
|
|
6
9
|
await maybeSuggestUpdate();
|
|
7
10
|
await runCli(process.argv);
|
|
@@ -12,4 +15,9 @@ main().catch((err) => {
|
|
|
12
15
|
console.error(formatCliError(message));
|
|
13
16
|
// Not process.exit(): on Windows it can abort while sockets are still closing.
|
|
14
17
|
process.exitCode = process.exitCode ? Number(process.exitCode) : 1;
|
|
18
|
+
// A stalled socket can ignore the abort and keep the process (and npx's cache lock)
|
|
19
|
+
// alive. Give it a moment to close, then exit anyway. unref: a clean drain wins.
|
|
20
|
+
if (err instanceof ApiError && err.code === "timeout") {
|
|
21
|
+
setTimeout(() => process.exit(), EXIT_GRACE_MS).unref();
|
|
22
|
+
}
|
|
15
23
|
});
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { CLI_API_OPTION, CLI_DESCRIPTION, cliCommand, cliEnvHelp, } from "@getmyenv/shared";
|
|
2
|
+
import { CLI_API_OPTION, CLI_DESCRIPTION, CLI_GLOBAL_OPTIONS, cliCommand, cliEnvHelp, formatCliError, } from "@getmyenv/shared";
|
|
3
3
|
import { backupCommand } from "./commands/backup.js";
|
|
4
4
|
import { claimCommand } from "./commands/claim.js";
|
|
5
5
|
import { contextCreateCommand, contextListCommand, contextUseCommand, } from "./commands/context.js";
|
|
6
6
|
import { exportCommand } from "./commands/export.js";
|
|
7
7
|
import { guestCommand } from "./commands/guest.js";
|
|
8
8
|
import { importCommand } from "./commands/import.js";
|
|
9
|
+
import { pushCommand } from "./commands/push.js";
|
|
9
10
|
import { lockCommand } from "./commands/lock.js";
|
|
10
11
|
import { lsCommand } from "./commands/ls.js";
|
|
11
12
|
import { restoreCommand } from "./commands/restore.js";
|
|
@@ -45,6 +46,7 @@ function define(parent, name, withApi = true) {
|
|
|
45
46
|
cmd.option(CLI_API_OPTION.flags, CLI_API_OPTION.description);
|
|
46
47
|
if (spec.usage)
|
|
47
48
|
cmd.usage(spec.usage);
|
|
49
|
+
cmd.showHelpAfterError(`See: npx getmyenv ${spec.name} --help`);
|
|
48
50
|
if (spec.examples) {
|
|
49
51
|
cmd.addHelpText("after", ["", "Examples:", ...spec.examples.map((e) => ` ${e}`)].join("\n"));
|
|
50
52
|
}
|
|
@@ -52,13 +54,20 @@ function define(parent, name, withApi = true) {
|
|
|
52
54
|
}
|
|
53
55
|
export function buildProgram(argv) {
|
|
54
56
|
const program = new Command();
|
|
57
|
+
const globalOption = (flags) => CLI_GLOBAL_OPTIONS.find((o) => o.flags === flags);
|
|
58
|
+
const help = cliCommand("help");
|
|
59
|
+
// Set before any subcommand exists: commander copies these settings to each one.
|
|
60
|
+
program
|
|
61
|
+
.configureOutput({ outputError: (str, write) => write(formatCliError(str.replace(/^error: /, ""))) })
|
|
62
|
+
.showHelpAfterError("See: npx getmyenv --help")
|
|
63
|
+
.helpOption("-h, --help", globalOption("-h, --help").description)
|
|
64
|
+
.helpCommand(`help ${help.args.map((a) => a.name).join(" ")}`, help.description);
|
|
55
65
|
program
|
|
56
66
|
.name("getmyenv")
|
|
57
67
|
.description(CLI_DESCRIPTION)
|
|
58
|
-
.version(CLI_VERSION)
|
|
68
|
+
.version(CLI_VERSION, "-V, --version", globalOption("-V, --version").description)
|
|
59
69
|
.option(CLI_API_OPTION.flags, CLI_API_OPTION.description)
|
|
60
70
|
.addHelpText("after", cliEnvHelp())
|
|
61
|
-
.helpCommand(true)
|
|
62
71
|
.action((opts) => startCommand(opts));
|
|
63
72
|
// The root owns --api-url wherever it appears, so hand it to the command that runs.
|
|
64
73
|
program.hook("preAction", (_root, actionCommand) => {
|
|
@@ -88,6 +97,7 @@ export function buildProgram(argv) {
|
|
|
88
97
|
define(program, "ls").action((opts) => lsCommand(opts));
|
|
89
98
|
define(program, "import").action((file, opts) => importCommand(file, opts));
|
|
90
99
|
define(program, "export").action((opts) => exportCommand(opts));
|
|
100
|
+
define(program, "push").action((provider, opts) => pushCommand(provider, opts));
|
|
91
101
|
define(program, "guest").action((opts) => guestCommand(opts));
|
|
92
102
|
define(program, "claim").action((opts) => claimCommand(opts));
|
|
93
103
|
define(program, "backup").action((opts) => backupCommand(opts));
|
package/dist/commands/backup.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import pc from "
|
|
2
|
+
import { pc } from "../lib/colors.js";
|
|
3
3
|
import { createBackupEnvelope, parsePayload, parseWrappedKey, serializeBackup, } from "@getmyenv/crypto";
|
|
4
4
|
import { slugify } from "@getmyenv/shared";
|
|
5
5
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
package/dist/commands/claim.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import pc from "
|
|
1
|
+
import { pcErr as pc } from "../lib/colors.js";
|
|
2
2
|
import { GUEST_GRACE_DAYS } from "@getmyenv/shared";
|
|
3
3
|
import { openSealedKey, sealContextKey, unwrapPrivateKey, wipe, } from "@getmyenv/crypto";
|
|
4
4
|
import { clearClaimFile, ensureGitignoreHasEnv, promptHidden, readClaimFile, readLine, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
|
|
@@ -146,7 +146,8 @@ export async function claimCommand(opts = {}) {
|
|
|
146
146
|
throw new Error("Unexpected claim status. Run claim again.");
|
|
147
147
|
const session = start;
|
|
148
148
|
const base = apiUrl.replace(/\/$/, "");
|
|
149
|
-
|
|
149
|
+
// After #, so the claim code never reaches the server, proxy logs or browser history requests.
|
|
150
|
+
const claimUrl = `${base}/claim#code=${encodeURIComponent(claim.claimCode)}&session=${encodeURIComponent(session.sessionId)}`;
|
|
150
151
|
console.error("\nOpen this URL to claim the project:\n");
|
|
151
152
|
console.error(` ${claimUrl}\n`);
|
|
152
153
|
console.error(`Enter this code on the page: ${pc.bold(session.userCode)}\n`);
|
package/dist/commands/context.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import pc from "
|
|
1
|
+
import { pc } from "../lib/colors.js";
|
|
2
2
|
import { decryptSecret, parsePayload } from "@getmyenv/crypto";
|
|
3
3
|
import { readProjectMeta, writeProjectMeta } from "../lib/fs.js";
|
|
4
4
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
@@ -11,7 +11,7 @@ export async function contextListCommand(opts) {
|
|
|
11
11
|
const width = Math.max(...who.contexts.map((c) => c.slug.length), 4);
|
|
12
12
|
for (const c of who.contexts) {
|
|
13
13
|
const marker = c.slug === current ? pc.green("*") : " ";
|
|
14
|
-
const prot = c.protection === "locked" ? pc.yellow("
|
|
14
|
+
const prot = c.protection === "locked" ? pc.yellow("Read-only") : pc.dim("open ");
|
|
15
15
|
console.log(`${marker} ${c.slug.padEnd(width)} ${prot} ${c.name}`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -24,7 +24,7 @@ export async function contextCreateCommand(name, opts) {
|
|
|
24
24
|
if (!source)
|
|
25
25
|
throw new Error(`No context "${opts.from}".`);
|
|
26
26
|
if (source.protection === "locked") {
|
|
27
|
-
throw new Error(`${source.slug} is
|
|
27
|
+
throw new Error(`${source.slug} is Read-only. Copy from it in the dashboard.`);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
const created = await client.request("POST", "/api/cli/contexts", { name });
|
|
@@ -60,7 +60,7 @@ export async function contextUseCommand(slug, opts) {
|
|
|
60
60
|
throw new Error(`No context "${slug}". Available: ${available}`);
|
|
61
61
|
}
|
|
62
62
|
if (ctx.protection === "locked" && who.token.kind !== "server") {
|
|
63
|
-
throw new Error(`${ctx.slug} is
|
|
63
|
+
throw new Error(`${ctx.slug} is Read-only. This folder's token reads open contexts only. Read-only contexts are read on servers with a server token.`);
|
|
64
64
|
}
|
|
65
65
|
const meta = readProjectMeta();
|
|
66
66
|
writeProjectMeta({ ...(meta ?? {}), context: ctx.slug });
|
package/dist/commands/export.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import pc from "
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { ensureGitignoreHas, ensureGitignoreHasEnv, parseEnvFile, writePrivateFile, } from "../lib/fs.js";
|
|
2
|
+
import { pc as pcOut, pcErr } from "../lib/colors.js";
|
|
3
|
+
import { EXPORT_WARNING, formatDotenvValue, plural } from "@getmyenv/shared";
|
|
4
|
+
import { assertFileTarget, ensureGitignoreHas, ensureGitignoreHasEnv, readEnvFile, writePrivateFile, } from "../lib/fs.js";
|
|
6
5
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
7
6
|
import { resolveContext } from "../lib/context.js";
|
|
8
7
|
import { confirmDiff } from "../lib/confirm.js";
|
|
8
|
+
import { decryptContext } from "../lib/read-context.js";
|
|
9
9
|
import { resolveValues } from "../lib/resolve.js";
|
|
10
|
-
import { contextKey } from "../lib/vault.js";
|
|
11
|
-
import { assertVariableNames } from "../lib/run-env.js";
|
|
12
10
|
/**
|
|
13
11
|
* `export`: write one context to a plaintext file, for tools that need one.
|
|
14
12
|
* Read-only contexts go through the same checks as `run`: server token,
|
|
@@ -16,20 +14,18 @@ import { assertVariableNames } from "../lib/run-env.js";
|
|
|
16
14
|
*/
|
|
17
15
|
export async function exportCommand(opts) {
|
|
18
16
|
const log = opts.stdout ? (l = "") => console.error(l) : (l = "") => console.log(l);
|
|
17
|
+
const pc = opts.stdout ? pcErr : pcOut;
|
|
18
|
+
const out = opts.output || ".env";
|
|
19
|
+
// Before any request: a bad -o path should not cost a Vault password prompt.
|
|
20
|
+
if (!opts.stdout)
|
|
21
|
+
assertFileTarget(out);
|
|
19
22
|
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl, quiet: opts.stdout });
|
|
20
23
|
const ctx = resolveContext(linked.who, linked.meta, opts.context);
|
|
21
24
|
log(pc.yellow(EXPORT_WARNING));
|
|
22
25
|
const data = ctx.protection === "locked"
|
|
23
26
|
? await resolveValues(linked.client, linked.who.project.id, ctx.slug, "export")
|
|
24
27
|
: await linked.client.request("GET", `/api/cli/values?context=${encodeURIComponent(ctx.slug)}`);
|
|
25
|
-
|
|
26
|
-
const remote = {};
|
|
27
|
-
if (data.values.length > 0) {
|
|
28
|
-
const key = await contextKey(linked, { ...ctx, sealedKey: data.sealedKey });
|
|
29
|
-
for (const v of data.values) {
|
|
30
|
-
remote[v.name] = decryptSecret(key, v.name, parsePayload(v.ciphertext));
|
|
31
|
-
}
|
|
32
|
-
}
|
|
28
|
+
const { values: remote } = await decryptContext(linked, ctx, data);
|
|
33
29
|
const lines = [
|
|
34
30
|
`# Exported by getmyenv from ${ctx.slug}. Plaintext. Do not commit.`,
|
|
35
31
|
...Object.entries(remote).map(([k, v]) => `${k}=${formatDotenvValue(v)}`),
|
|
@@ -39,20 +35,21 @@ export async function exportCommand(opts) {
|
|
|
39
35
|
process.stdout.write(lines.join("\n") + "\n");
|
|
40
36
|
return;
|
|
41
37
|
}
|
|
42
|
-
const out = opts.output || ".env";
|
|
43
|
-
if (fs.lstatSync(out, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
44
|
-
throw new Error(`${out} is a symlink. Refusing to write through it.`);
|
|
45
|
-
}
|
|
46
38
|
if (fs.existsSync(out)) {
|
|
47
|
-
const local =
|
|
39
|
+
const { values: local, skipped } = readEnvFile(out);
|
|
48
40
|
const conflicts = Object.keys(remote)
|
|
49
41
|
.filter((k) => k in local && local[k] !== remote[k])
|
|
50
42
|
.map((name) => ({ name, detail: "local value differs" }));
|
|
51
43
|
const dropped = Object.keys(local)
|
|
52
44
|
.filter((k) => !(k in remote))
|
|
53
|
-
.map((name) => ({ name, detail: "only in the local file
|
|
45
|
+
.map((name) => ({ name, detail: "only in the local file" }));
|
|
46
|
+
// Names getmyenv cannot hold (like lowercase) would vanish without a word.
|
|
47
|
+
for (const name of skipped) {
|
|
48
|
+
dropped.push({ name, detail: "not a valid name here, only in the local file" });
|
|
49
|
+
}
|
|
54
50
|
const ok = await confirmDiff({
|
|
55
|
-
conflicts
|
|
51
|
+
conflicts,
|
|
52
|
+
removed: dropped,
|
|
56
53
|
action: `Overwrite ${out}`,
|
|
57
54
|
yes: opts.yes,
|
|
58
55
|
});
|
|
@@ -64,5 +61,5 @@ export async function exportCommand(opts) {
|
|
|
64
61
|
writePrivateFile(out, lines.join("\n") + "\n", { overwrite: true });
|
|
65
62
|
ensureGitignoreHasEnv();
|
|
66
63
|
ensureGitignoreHas(out);
|
|
67
|
-
log(`${pc.green("✓")} Wrote ${Object.keys(remote).length
|
|
64
|
+
log(`${pc.green("✓")} Wrote ${plural(Object.keys(remote).length, "variable")} from ${ctx.slug} to ${out}`);
|
|
68
65
|
}
|
package/dist/commands/guest.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type GuestOpts = {
|
|
|
3
3
|
apiUrl?: string;
|
|
4
4
|
};
|
|
5
5
|
type CreateGuestOpts = GuestOpts & {
|
|
6
|
-
/**
|
|
6
|
+
/** The caller says that project.json now points to the new project. Without it, this function says so. */
|
|
7
7
|
replaceProjectMeta?: boolean;
|
|
8
8
|
};
|
|
9
9
|
export declare function folderNameHint(): string;
|
package/dist/commands/guest.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import pc from "
|
|
3
|
+
import { pcErr as pc } from "../lib/colors.js";
|
|
4
4
|
import { createUserKeys, wipe } from "@getmyenv/crypto";
|
|
5
5
|
import { FACTS, TEMPLATE_FILE } from "@getmyenv/shared";
|
|
6
|
-
import { ensureGitignoreHasEnv, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
|
|
6
|
+
import { ensureGitignoreHasEnv, readClaimFile, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
|
|
7
7
|
import { readFolderToken, writeFolderToken } from "../lib/credentials.js";
|
|
8
8
|
import { ApiClient, resolveApiUrl } from "../lib/api.js";
|
|
9
9
|
import { readTemplateFile } from "../lib/template-file.js";
|
|
@@ -16,8 +16,16 @@ export function folderNameHint() {
|
|
|
16
16
|
* claim.json holds the guest passphrase that wraps the guest key until claim.
|
|
17
17
|
*/
|
|
18
18
|
export async function createGuestProject(opts = {}) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Only a token links this folder on this machine. A committed project.json
|
|
20
|
+
// alone (a clone) is replaced once the new project exists.
|
|
21
|
+
const previous = readProjectMeta();
|
|
22
|
+
if (readFolderToken()) {
|
|
23
|
+
const name = previous?.projectName ?? previous?.projectId;
|
|
24
|
+
throw new Error(`This folder is already linked${name ? ` to ${name}` : ""}. See it: npx getmyenv status. To start over: npx getmyenv unlink, then npx getmyenv guest`);
|
|
25
|
+
}
|
|
26
|
+
// claim.json is the only copy of a guest project's passphrase. Never write over it.
|
|
27
|
+
if (readClaimFile()) {
|
|
28
|
+
throw new Error("This folder has a guest project (claim.json) but no token on this machine. Claim it: npx getmyenv claim");
|
|
21
29
|
}
|
|
22
30
|
const apiUrl = resolveApiUrl(opts.apiUrl);
|
|
23
31
|
const name = (opts.name?.trim() || folderNameHint()).slice(0, 120);
|
|
@@ -49,9 +57,11 @@ export async function createGuestProject(opts = {}) {
|
|
|
49
57
|
guestExpiresAt: created.guestExpiresAt,
|
|
50
58
|
});
|
|
51
59
|
ensureGitignoreHasEnv();
|
|
52
|
-
const daysLeft = Math.max(0, Math.ceil((new Date(created.guestExpiresAt).getTime() - Date.now()) / 86_400_000));
|
|
53
60
|
console.error(`${pc.green("✓")} Guest project ${pc.bold(created.projectName)}. Context: development.`);
|
|
54
|
-
|
|
61
|
+
if (previous?.projectId && previous.projectId !== created.projectId && !opts.replaceProjectMeta) {
|
|
62
|
+
console.error(pc.dim(`project.json now points to ${created.projectName}. It pointed to ${previous.projectName ?? previous.projectId}.`));
|
|
63
|
+
}
|
|
64
|
+
console.error(pc.dim(`${FACTS.guestShort} ${FACTS.committedFiles}`));
|
|
55
65
|
if (process.stdin.isTTY) {
|
|
56
66
|
console.error(pc.dim("claim.json holds the claim code and guest passphrase. Shown once, save them to claim later:"));
|
|
57
67
|
console.error(` ${pc.dim("Claim code:")} ${created.claimCode}`);
|
|
@@ -75,6 +85,7 @@ export function guestNextLines() {
|
|
|
75
85
|
" npx getmyenv export # writes a plaintext .env",
|
|
76
86
|
"",
|
|
77
87
|
`For AI agents: ${FACTS.agentRunOrExport}`,
|
|
88
|
+
`For AI agents: ${FACTS.agentSecretInput}`,
|
|
78
89
|
];
|
|
79
90
|
}
|
|
80
91
|
function colorNextLine(line) {
|
package/dist/commands/import.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import pc from "
|
|
4
|
-
import { FACTS } from "@getmyenv/shared";
|
|
5
|
-
import { contextSlugFromEnvFile, ensureGitignoreHasEnv,
|
|
3
|
+
import { pcErr as pc } from "../lib/colors.js";
|
|
4
|
+
import { FACTS, isOwnEnvName, ownEnvNameError, plural, unsafeEnvWarning, VARIABLE_NAME_HELP } from "@getmyenv/shared";
|
|
5
|
+
import { contextSlugFromEnvFile, duplicateEnvNames, ensureGitignoreHasEnv, readEnvFile, promptConfirm, rootEnvFiles, } from "../lib/fs.js";
|
|
6
6
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
7
7
|
import { defaultContextSlug, findContext, resolveContext } from "../lib/context.js";
|
|
8
8
|
import { confirmDiff } from "../lib/confirm.js";
|
|
9
|
-
import { printDidYouMean } from "../lib/known-keys.js";
|
|
9
|
+
import { printDidYouMean, printPublicSecretWarnings } from "../lib/known-keys.js";
|
|
10
|
+
import { refreshKnownKeys } from "../lib/known-keys-cache.js";
|
|
10
11
|
import { syncFromLinked } from "../lib/template-file.js";
|
|
11
12
|
import { uploadValues } from "../lib/upload.js";
|
|
12
13
|
import { runLine } from "./start.js";
|
|
@@ -45,7 +46,7 @@ async function ensureTargetContext(linked, slug, opts) {
|
|
|
45
46
|
if (found)
|
|
46
47
|
return found;
|
|
47
48
|
if (linked.who.isGuest || linked.who.token.kind !== "folder") {
|
|
48
|
-
const why = linked.who.isGuest ? FACTS.guestContexts : "Server tokens
|
|
49
|
+
const why = linked.who.isGuest ? FACTS.guestContexts : "Server tokens cannot change values.";
|
|
49
50
|
console.error(pc.yellow(`Skipped ${slug}: no such context. ${why}`));
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
@@ -69,6 +70,7 @@ async function ensureTargetContext(linked, slug, opts) {
|
|
|
69
70
|
/** `import [file]`: encrypt .env values locally and upload them to contexts. */
|
|
70
71
|
export async function importCommand(file, opts) {
|
|
71
72
|
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
73
|
+
await refreshKnownKeys(linked.apiUrl);
|
|
72
74
|
const { who, meta } = linked;
|
|
73
75
|
const explicitContext = opts.context ? resolveContext(who, meta, opts.context).slug : undefined;
|
|
74
76
|
let files;
|
|
@@ -76,6 +78,9 @@ export async function importCommand(file, opts) {
|
|
|
76
78
|
const resolved = path.resolve(process.cwd(), file);
|
|
77
79
|
if (!fs.existsSync(resolved))
|
|
78
80
|
throw new Error(`File not found: ${file}`);
|
|
81
|
+
if (!fs.statSync(resolved).isFile()) {
|
|
82
|
+
throw new Error(`${file} is not a file. Pass an env file: npx getmyenv import path/to/.env`);
|
|
83
|
+
}
|
|
79
84
|
files = [resolved];
|
|
80
85
|
}
|
|
81
86
|
else {
|
|
@@ -106,16 +111,37 @@ export async function importCommand(file, opts) {
|
|
|
106
111
|
continue;
|
|
107
112
|
}
|
|
108
113
|
if (ctx.protection === "locked") {
|
|
109
|
-
console.error(pc.yellow(`Skipped ${label}: ${ctx.slug} is
|
|
114
|
+
console.error(pc.yellow(`Skipped ${label}: ${ctx.slug} is Read-only. ${FACTS.readOnlyWho}`));
|
|
110
115
|
skipped++;
|
|
111
116
|
continue;
|
|
112
117
|
}
|
|
113
118
|
const pairs = {};
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
const badNames = [];
|
|
120
|
+
for (const f of target.files) {
|
|
121
|
+
const read = readEnvFile(f);
|
|
122
|
+
Object.assign(pairs, read.values);
|
|
123
|
+
badNames.push(...read.skipped);
|
|
124
|
+
const rel = path.relative(process.cwd(), f) || f;
|
|
125
|
+
for (const dup of duplicateEnvNames(f)) {
|
|
126
|
+
console.error(pc.yellow(`${dup.name} appears ${dup.count} times in ${rel}. The last one is used.`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (badNames.length > 0) {
|
|
130
|
+
console.error(pc.yellow(`Skipped ${[...new Set(badNames)].join(", ")}. ${VARIABLE_NAME_HELP}`));
|
|
131
|
+
}
|
|
132
|
+
const own = ownEnvNameError(Object.keys(pairs));
|
|
133
|
+
if (own) {
|
|
134
|
+
for (const name of Object.keys(pairs))
|
|
135
|
+
if (isOwnEnvName(name))
|
|
136
|
+
delete pairs[name];
|
|
137
|
+
console.error(pc.yellow(`Skipped ${own}`));
|
|
138
|
+
}
|
|
116
139
|
const names = Object.keys(pairs);
|
|
117
140
|
if (names.length === 0) {
|
|
118
141
|
console.error(pc.dim(`Skipped ${label}: no variables.`));
|
|
142
|
+
// A file named on the command line that holds nothing is an error, not a quiet success.
|
|
143
|
+
if (file)
|
|
144
|
+
skipped++;
|
|
119
145
|
continue;
|
|
120
146
|
}
|
|
121
147
|
const statusOf = (name) => who.keys.find((k) => k.name === name)?.status[ctx.slug] ?? "missing";
|
|
@@ -131,14 +157,20 @@ export async function importCommand(file, opts) {
|
|
|
131
157
|
console.error(` ${opts.prune ? pc.red("-") : pc.dim("=")} ${name}${pc.dim(opts.prune ? " (unset, not in file)" : " (kept, not in file)")}`);
|
|
132
158
|
}
|
|
133
159
|
printDidYouMean(names, " ");
|
|
160
|
+
printPublicSecretWarnings(pairs, " ");
|
|
161
|
+
const unsafe = unsafeEnvWarning(names);
|
|
162
|
+
if (unsafe)
|
|
163
|
+
console.error(pc.yellow(` ${unsafe}`));
|
|
134
164
|
if (opts.dryRun) {
|
|
135
165
|
console.error("");
|
|
136
166
|
continue;
|
|
137
167
|
}
|
|
138
168
|
if (opts.prune && hostedOnly.length > 0) {
|
|
139
169
|
const ok = await confirmDiff({
|
|
140
|
-
conflicts:
|
|
141
|
-
|
|
170
|
+
conflicts: [],
|
|
171
|
+
removed: hostedOnly.map((name) => ({ name, detail: `unset in ${ctx.slug}` })),
|
|
172
|
+
removedLabel: "not in the file",
|
|
173
|
+
action: `Unset ${plural(hostedOnly.length, "value")} not in the file`,
|
|
142
174
|
yes: opts.yes,
|
|
143
175
|
});
|
|
144
176
|
if (!ok) {
|
package/dist/commands/lock.js
CHANGED
package/dist/commands/ls.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import pc from "
|
|
1
|
+
import { pc } from "../lib/colors.js";
|
|
2
2
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
3
3
|
import { resolveContext } from "../lib/context.js";
|
|
4
4
|
import { syncFromLinked } from "../lib/template-file.js";
|
|
@@ -25,7 +25,7 @@ export async function lsCommand(opts) {
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
const nameWidth = Math.max(4, ...who.keys.map((k) => k.name.length));
|
|
28
|
-
const headers = contexts.map((c) => (c.protection === "locked" ? `${c.slug} (
|
|
28
|
+
const headers = contexts.map((c) => (c.protection === "locked" ? `${c.slug} (Read-only)` : c.slug));
|
|
29
29
|
const widths = headers.map((h) => Math.max(h.length, 8));
|
|
30
30
|
console.log(pc.bold(["NAME".padEnd(nameWidth), ...headers.map((h, i) => h.padEnd(widths[i]))].join(" ")));
|
|
31
31
|
for (const k of who.keys) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PlannedVar, PushAdapter, PushProviderId } from "../providers/types.js";
|
|
2
|
+
export type PushOpts = {
|
|
3
|
+
context?: string;
|
|
4
|
+
apiUrl?: string;
|
|
5
|
+
project?: string;
|
|
6
|
+
team?: string;
|
|
7
|
+
site?: string;
|
|
8
|
+
target?: string;
|
|
9
|
+
prune?: boolean;
|
|
10
|
+
dryRun?: boolean;
|
|
11
|
+
yes?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function createAdapter(provider: PushProviderId, token: string, opts: PushOpts): PushAdapter;
|
|
14
|
+
/** Names-only plan. Values never leave this function except to the platform. */
|
|
15
|
+
export declare function buildPlan(adapter: PushAdapter, values: Record<string, string>, remote: string[], prune: boolean): {
|
|
16
|
+
upserts: PlannedVar[];
|
|
17
|
+
prune: string[];
|
|
18
|
+
reserved: string[];
|
|
19
|
+
};
|
|
20
|
+
export declare function formatPlan(upserts: PlannedVar[], prune: string[]): string[];
|
|
21
|
+
/**
|
|
22
|
+
* `push vercel|netlify`: decrypt one context on this machine and write it to
|
|
23
|
+
* the platform with the user's own token. The token never reaches getmyenv.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pushCommand(providerArg: string, opts: PushOpts): Promise<void>;
|