getmyenv 0.13.0 → 0.14.1
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 +14 -12
- package/dist/bin.js +8 -0
- package/dist/cli.js +11 -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 +17 -11
- package/dist/commands/guest.d.ts +1 -1
- package/dist/commands/guest.js +19 -6
- package/dist/commands/import.js +38 -9
- package/dist/commands/lock.js +1 -1
- package/dist/commands/ls.js +2 -2
- package/dist/commands/push.js +6 -5
- package/dist/commands/restore.js +3 -3
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +61 -12
- package/dist/commands/set.d.ts +16 -0
- package/dist/commands/set.js +91 -12
- package/dist/commands/start.js +7 -7
- package/dist/commands/status.js +3 -3
- package/dist/commands/template.js +3 -3
- 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 +107 -28
- package/dist/lib/known-keys-cache.d.ts +3 -2
- package/dist/lib/known-keys-cache.js +19 -5
- package/dist/lib/known-keys.js +1 -1
- package/dist/lib/recovery-code.js +1 -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.js +15 -12
- package/dist/providers/netlify.js +3 -1
- package/dist/providers/vercel.js +3 -1
- 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.1):
|
|
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)
|
|
@@ -80,7 +80,8 @@ Commands:
|
|
|
80
80
|
push [options] <provider> Copy a context to Vercel or Netlify with your
|
|
81
81
|
platform token (VERCEL_TOKEN, NETLIFY_AUTH_TOKEN)
|
|
82
82
|
guest [options] Create a guest project without an account. It
|
|
83
|
-
|
|
83
|
+
stays active while you use it, up to 180 days.
|
|
84
|
+
Claim it to keep it.
|
|
84
85
|
claim [options] Claim a guest project (reads .getmyenv/claim.json,
|
|
85
86
|
or asks for the claim code)
|
|
86
87
|
backup [options] Write an encrypted backup of open contexts
|
|
@@ -90,7 +91,7 @@ Commands:
|
|
|
90
91
|
(OS keychain)
|
|
91
92
|
unlink [options] Revoke this folder's token and remove local
|
|
92
93
|
.getmyenv files
|
|
93
|
-
help [command]
|
|
94
|
+
help [command] Show help for a command (same as <command> --help)
|
|
94
95
|
|
|
95
96
|
Environment:
|
|
96
97
|
GETMYENV_TOKEN Token for CI and servers (instead of .getmyenv/token.json). A server token holds its context key.
|
|
@@ -99,6 +100,7 @@ Environment:
|
|
|
99
100
|
GETMYENV_SKIP_UPDATE Set to 1 to skip the update notice
|
|
100
101
|
GETMYENV_KEYCHAIN Set to 0 to always ask for the Vault password
|
|
101
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)
|
|
102
104
|
```
|
|
103
105
|
<!-- /cli:help -->
|
|
104
106
|
|
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,5 +1,5 @@
|
|
|
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";
|
|
@@ -46,6 +46,7 @@ function define(parent, name, withApi = true) {
|
|
|
46
46
|
cmd.option(CLI_API_OPTION.flags, CLI_API_OPTION.description);
|
|
47
47
|
if (spec.usage)
|
|
48
48
|
cmd.usage(spec.usage);
|
|
49
|
+
cmd.showHelpAfterError(`See: npx getmyenv ${spec.name} --help`);
|
|
49
50
|
if (spec.examples) {
|
|
50
51
|
cmd.addHelpText("after", ["", "Examples:", ...spec.examples.map((e) => ` ${e}`)].join("\n"));
|
|
51
52
|
}
|
|
@@ -53,13 +54,20 @@ function define(parent, name, withApi = true) {
|
|
|
53
54
|
}
|
|
54
55
|
export function buildProgram(argv) {
|
|
55
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);
|
|
56
65
|
program
|
|
57
66
|
.name("getmyenv")
|
|
58
67
|
.description(CLI_DESCRIPTION)
|
|
59
|
-
.version(CLI_VERSION)
|
|
68
|
+
.version(CLI_VERSION, "-V, --version", globalOption("-V, --version").description)
|
|
60
69
|
.option(CLI_API_OPTION.flags, CLI_API_OPTION.description)
|
|
61
70
|
.addHelpText("after", cliEnvHelp())
|
|
62
|
-
.helpCommand(true)
|
|
63
71
|
.action((opts) => startCommand(opts));
|
|
64
72
|
// The root owns --api-url wherever it appears, so hand it to the command that runs.
|
|
65
73
|
program.hook("preAction", (_root, actionCommand) => {
|
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,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import pc from "
|
|
3
|
-
import { EXPORT_WARNING, formatDotenvValue } from "@getmyenv/shared";
|
|
4
|
-
import { ensureGitignoreHas, ensureGitignoreHasEnv,
|
|
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";
|
|
5
5
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
6
6
|
import { resolveContext } from "../lib/context.js";
|
|
7
7
|
import { confirmDiff } from "../lib/confirm.js";
|
|
@@ -14,6 +14,11 @@ import { resolveValues } from "../lib/resolve.js";
|
|
|
14
14
|
*/
|
|
15
15
|
export async function exportCommand(opts) {
|
|
16
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);
|
|
17
22
|
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl, quiet: opts.stdout });
|
|
18
23
|
const ctx = resolveContext(linked.who, linked.meta, opts.context);
|
|
19
24
|
log(pc.yellow(EXPORT_WARNING));
|
|
@@ -30,20 +35,21 @@ export async function exportCommand(opts) {
|
|
|
30
35
|
process.stdout.write(lines.join("\n") + "\n");
|
|
31
36
|
return;
|
|
32
37
|
}
|
|
33
|
-
const out = opts.output || ".env";
|
|
34
|
-
if (fs.lstatSync(out, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
35
|
-
throw new Error(`${out} is a symlink. Refusing to write through it.`);
|
|
36
|
-
}
|
|
37
38
|
if (fs.existsSync(out)) {
|
|
38
|
-
const local =
|
|
39
|
+
const { values: local, skipped } = readEnvFile(out);
|
|
39
40
|
const conflicts = Object.keys(remote)
|
|
40
41
|
.filter((k) => k in local && local[k] !== remote[k])
|
|
41
42
|
.map((name) => ({ name, detail: "local value differs" }));
|
|
42
43
|
const dropped = Object.keys(local)
|
|
43
44
|
.filter((k) => !(k in remote))
|
|
44
|
-
.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
|
+
}
|
|
45
50
|
const ok = await confirmDiff({
|
|
46
|
-
conflicts
|
|
51
|
+
conflicts,
|
|
52
|
+
removed: dropped,
|
|
47
53
|
action: `Overwrite ${out}`,
|
|
48
54
|
yes: opts.yes,
|
|
49
55
|
});
|
|
@@ -55,5 +61,5 @@ export async function exportCommand(opts) {
|
|
|
55
61
|
writePrivateFile(out, lines.join("\n") + "\n", { overwrite: true });
|
|
56
62
|
ensureGitignoreHasEnv();
|
|
57
63
|
ensureGitignoreHas(out);
|
|
58
|
-
log(`${pc.green("✓")} Wrote ${Object.keys(remote).length
|
|
64
|
+
log(`${pc.green("✓")} Wrote ${plural(Object.keys(remote).length, "variable")} from ${ctx.slug} to ${out}`);
|
|
59
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,10 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
import pc from "
|
|
4
|
+
import { pcErr as pc } from "../lib/colors.js";
|
|
4
5
|
import { createUserKeys, wipe } from "@getmyenv/crypto";
|
|
5
6
|
import { FACTS, TEMPLATE_FILE } from "@getmyenv/shared";
|
|
6
|
-
import { ensureGitignoreHasEnv, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
|
|
7
|
+
import { ensureGitignoreHasEnv, claimFilePath, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
|
|
7
8
|
import { readFolderToken, writeFolderToken } from "../lib/credentials.js";
|
|
8
9
|
import { ApiClient, resolveApiUrl } from "../lib/api.js";
|
|
9
10
|
import { readTemplateFile } from "../lib/template-file.js";
|
|
@@ -16,8 +17,17 @@ export function folderNameHint() {
|
|
|
16
17
|
* claim.json holds the guest passphrase that wraps the guest key until claim.
|
|
17
18
|
*/
|
|
18
19
|
export async function createGuestProject(opts = {}) {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// Only a token links this folder on this machine. A committed project.json
|
|
21
|
+
// alone (a clone) is replaced once the new project exists.
|
|
22
|
+
const previous = readProjectMeta();
|
|
23
|
+
if (readFolderToken()) {
|
|
24
|
+
const name = previous?.projectName ?? previous?.projectId;
|
|
25
|
+
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`);
|
|
26
|
+
}
|
|
27
|
+
// claim.json is the only copy of a guest project's passphrase. Never write over
|
|
28
|
+
// it, even when it does not parse.
|
|
29
|
+
if (fs.existsSync(claimFilePath())) {
|
|
30
|
+
throw new Error("This folder has a guest project (claim.json) but no token on this machine. Claim it: npx getmyenv claim");
|
|
21
31
|
}
|
|
22
32
|
const apiUrl = resolveApiUrl(opts.apiUrl);
|
|
23
33
|
const name = (opts.name?.trim() || folderNameHint()).slice(0, 120);
|
|
@@ -49,9 +59,11 @@ export async function createGuestProject(opts = {}) {
|
|
|
49
59
|
guestExpiresAt: created.guestExpiresAt,
|
|
50
60
|
});
|
|
51
61
|
ensureGitignoreHasEnv();
|
|
52
|
-
const daysLeft = Math.max(0, Math.ceil((new Date(created.guestExpiresAt).getTime() - Date.now()) / 86_400_000));
|
|
53
62
|
console.error(`${pc.green("✓")} Guest project ${pc.bold(created.projectName)}. Context: development.`);
|
|
54
|
-
|
|
63
|
+
if (previous?.projectId && previous.projectId !== created.projectId && !opts.replaceProjectMeta) {
|
|
64
|
+
console.error(pc.dim(`project.json now points to ${created.projectName}. It pointed to ${previous.projectName ?? previous.projectId}.`));
|
|
65
|
+
}
|
|
66
|
+
console.error(pc.dim(`${FACTS.guestShort} ${FACTS.committedFiles}`));
|
|
55
67
|
if (process.stdin.isTTY) {
|
|
56
68
|
console.error(pc.dim("claim.json holds the claim code and guest passphrase. Shown once, save them to claim later:"));
|
|
57
69
|
console.error(` ${pc.dim("Claim code:")} ${created.claimCode}`);
|
|
@@ -75,6 +87,7 @@ export function guestNextLines() {
|
|
|
75
87
|
" npx getmyenv export # writes a plaintext .env",
|
|
76
88
|
"",
|
|
77
89
|
`For AI agents: ${FACTS.agentRunOrExport}`,
|
|
90
|
+
`For AI agents: ${FACTS.agentSecretInput}`,
|
|
78
91
|
];
|
|
79
92
|
}
|
|
80
93
|
function colorNextLine(line) {
|
package/dist/commands/import.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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";
|
|
@@ -46,7 +46,7 @@ async function ensureTargetContext(linked, slug, opts) {
|
|
|
46
46
|
if (found)
|
|
47
47
|
return found;
|
|
48
48
|
if (linked.who.isGuest || linked.who.token.kind !== "folder") {
|
|
49
|
-
const why = linked.who.isGuest ? FACTS.guestContexts : "Server tokens
|
|
49
|
+
const why = linked.who.isGuest ? FACTS.guestContexts : "Server tokens cannot change values.";
|
|
50
50
|
console.error(pc.yellow(`Skipped ${slug}: no such context. ${why}`));
|
|
51
51
|
return null;
|
|
52
52
|
}
|
|
@@ -78,6 +78,9 @@ export async function importCommand(file, opts) {
|
|
|
78
78
|
const resolved = path.resolve(process.cwd(), file);
|
|
79
79
|
if (!fs.existsSync(resolved))
|
|
80
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
|
+
}
|
|
81
84
|
files = [resolved];
|
|
82
85
|
}
|
|
83
86
|
else {
|
|
@@ -108,16 +111,37 @@ export async function importCommand(file, opts) {
|
|
|
108
111
|
continue;
|
|
109
112
|
}
|
|
110
113
|
if (ctx.protection === "locked") {
|
|
111
|
-
console.error(pc.yellow(`Skipped ${label}: ${ctx.slug} is
|
|
114
|
+
console.error(pc.yellow(`Skipped ${label}: ${ctx.slug} is Read-only. ${FACTS.readOnlyWho}`));
|
|
112
115
|
skipped++;
|
|
113
116
|
continue;
|
|
114
117
|
}
|
|
115
118
|
const pairs = {};
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
|
118
139
|
const names = Object.keys(pairs);
|
|
119
140
|
if (names.length === 0) {
|
|
120
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++;
|
|
121
145
|
continue;
|
|
122
146
|
}
|
|
123
147
|
const statusOf = (name) => who.keys.find((k) => k.name === name)?.status[ctx.slug] ?? "missing";
|
|
@@ -134,14 +158,19 @@ export async function importCommand(file, opts) {
|
|
|
134
158
|
}
|
|
135
159
|
printDidYouMean(names, " ");
|
|
136
160
|
printPublicSecretWarnings(pairs, " ");
|
|
161
|
+
const unsafe = unsafeEnvWarning(names);
|
|
162
|
+
if (unsafe)
|
|
163
|
+
console.error(pc.yellow(` ${unsafe}`));
|
|
137
164
|
if (opts.dryRun) {
|
|
138
165
|
console.error("");
|
|
139
166
|
continue;
|
|
140
167
|
}
|
|
141
168
|
if (opts.prune && hostedOnly.length > 0) {
|
|
142
169
|
const ok = await confirmDiff({
|
|
143
|
-
conflicts:
|
|
144
|
-
|
|
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`,
|
|
145
174
|
yes: opts.yes,
|
|
146
175
|
});
|
|
147
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) {
|
package/dist/commands/push.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import pc from "
|
|
1
|
+
import { pc } from "../lib/colors.js";
|
|
2
2
|
import { pushFingerprint } from "@getmyenv/crypto";
|
|
3
3
|
import { describePushDestination, PUSH_PROVIDER_TARGETS, PUSH_PROVIDER_TITLES, PUSH_TOKEN_ENV, publicSecretWarning, } from "@getmyenv/shared";
|
|
4
4
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
@@ -79,15 +79,16 @@ export async function pushCommand(providerArg, opts) {
|
|
|
79
79
|
if (provider === "netlify" && (opts.project || opts.team)) {
|
|
80
80
|
throw new Error("--project and --team are for vercel. Use --site for netlify.");
|
|
81
81
|
}
|
|
82
|
+
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
83
|
+
if (linked.who.isGuest) {
|
|
84
|
+
throw new Error("Push needs an account. Claim this project first: npx getmyenv claim");
|
|
85
|
+
}
|
|
86
|
+
// After the link and guest checks: a platform token does not help an unlinked folder or a guest.
|
|
82
87
|
const tokenEnv = PUSH_TOKEN_ENV[provider];
|
|
83
88
|
const token = process.env[tokenEnv]?.trim();
|
|
84
89
|
if (!token) {
|
|
85
90
|
throw new Error(`Set ${tokenEnv} to your ${title} token. Create one at ${TOKEN_URLS[provider]}`);
|
|
86
91
|
}
|
|
87
|
-
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
88
|
-
if (linked.who.isGuest) {
|
|
89
|
-
throw new Error("Push needs an account. Claim this project first: npx getmyenv claim");
|
|
90
|
-
}
|
|
91
92
|
const ctx = resolveContext(linked.who, linked.meta, opts.context);
|
|
92
93
|
const adapter = createAdapter(provider, token, { ...opts, target });
|
|
93
94
|
const dest = await adapter.destination();
|
package/dist/commands/restore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import pc from "
|
|
2
|
+
import { pcErr as pc } from "../lib/colors.js";
|
|
3
3
|
import { encryptSecret, parseBackup, restoreValuesFromBackup, serializePayload, } from "@getmyenv/crypto";
|
|
4
4
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
5
5
|
import { findContext } from "../lib/context.js";
|
|
@@ -64,7 +64,7 @@ export async function restoreCommand(file, opts) {
|
|
|
64
64
|
const note = !target
|
|
65
65
|
? pc.yellow(" (no such context here, skipped)")
|
|
66
66
|
: target.protection === "locked"
|
|
67
|
-
? pc.yellow(" (
|
|
67
|
+
? pc.yellow(" (Read-only, skipped)")
|
|
68
68
|
: "";
|
|
69
69
|
console.error(` ${slug}: ${count} value${count === 1 ? "" : "s"}${note}`);
|
|
70
70
|
}
|
|
@@ -105,6 +105,6 @@ export async function restoreCommand(file, opts) {
|
|
|
105
105
|
const skipped = [...perContext.keys()].filter((slug) => !writable(slug));
|
|
106
106
|
console.log(`${pc.green("✓")} Restored ${res.restored} values into ${res.projectName}`);
|
|
107
107
|
if (skipped.length > 0) {
|
|
108
|
-
console.log(pc.yellow(`Skipped: ${skipped.join(", ")}. Restore
|
|
108
|
+
console.log(pc.yellow(`Skipped: ${skipped.join(", ")}. Restore Read-only contexts in the dashboard.`));
|
|
109
109
|
}
|
|
110
110
|
}
|
package/dist/commands/run.d.ts
CHANGED
package/dist/commands/run.js
CHANGED
|
@@ -1,38 +1,82 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import os from "node:os";
|
|
3
|
-
import pc from "
|
|
3
|
+
import { pcErr as pc } from "../lib/colors.js";
|
|
4
4
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
5
5
|
import { resolveContext } from "../lib/context.js";
|
|
6
6
|
import { pendingFirstRunHints } from "../lib/first-run.js";
|
|
7
7
|
import { readContextValues } from "../lib/read-context.js";
|
|
8
|
-
import {
|
|
8
|
+
import { FACTS, unsafeEnvError } from "@getmyenv/shared";
|
|
9
|
+
import { ApiError } from "../lib/api.js";
|
|
10
|
+
import { explainLines, mergeEnv, runContextLabel, signalExitCode, validationError, isWindowsBatch, windowsCommandLine, } from "../lib/run-env.js";
|
|
9
11
|
/** Status lines go to stderr so the child's stdout stays clean for pipes. */
|
|
10
12
|
const log = (line = "") => console.error(line);
|
|
13
|
+
/** An unreachable or silent server stops run before the app starts. Say why. */
|
|
14
|
+
async function withRunNeedsServer(work) {
|
|
15
|
+
try {
|
|
16
|
+
return await work();
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err instanceof ApiError && err.status === 0) {
|
|
20
|
+
throw new ApiError(`${err.message}\n${FACTS.runNeedsServer}`, 0, err.code);
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Stop the child when this process is told to stop. On Windows the child runs
|
|
27
|
+
* under cmd.exe, and killing cmd leaves the app running, so end the whole tree.
|
|
28
|
+
* Best effort: errors are ignored.
|
|
29
|
+
*/
|
|
30
|
+
function stopChild(pid, sig, kill) {
|
|
31
|
+
if (process.platform === "win32" && pid) {
|
|
32
|
+
try {
|
|
33
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* fall back to kill */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
kill();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* already gone */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
11
47
|
/** Resolve, validate, merge, spawn. */
|
|
12
48
|
export async function runCommand(opts, childArgs) {
|
|
13
49
|
if (childArgs.length === 0 && !opts.explain) {
|
|
14
50
|
throw new Error("Usage: npx getmyenv run [context] -- <command>");
|
|
15
51
|
}
|
|
16
|
-
const linked = await
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
52
|
+
const { linked, res } = await withRunNeedsServer(async () => {
|
|
53
|
+
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
54
|
+
const ctx = resolveContext(linked.who, linked.meta, opts.context);
|
|
55
|
+
return { linked, res: await readContextValues(linked, ctx.slug, "run") };
|
|
56
|
+
});
|
|
57
|
+
const { who } = linked;
|
|
20
58
|
const merged = mergeEnv(process.env, res.values, res.missing, opts.keepExisting === true);
|
|
21
59
|
const label = runContextLabel(res.context);
|
|
22
60
|
if (opts.explain) {
|
|
23
61
|
log(pc.bold(`${who.project.name} / ${label}`));
|
|
24
|
-
for (const line of explainLines(merged, res.context.slug))
|
|
62
|
+
for (const line of explainLines(merged, res.context.slug, { allowUnsafeEnv: opts.allowUnsafeEnv }))
|
|
25
63
|
log(line);
|
|
26
64
|
log();
|
|
27
65
|
}
|
|
28
66
|
const invalid = validationError(merged, res.context.slug, opts.allowMissing === true);
|
|
29
67
|
if (invalid)
|
|
30
68
|
throw new Error(invalid);
|
|
69
|
+
const unsafe = opts.allowUnsafeEnv ? null : unsafeEnvError(merged.applied, res.context.slug);
|
|
70
|
+
if (unsafe)
|
|
71
|
+
throw new Error(unsafe);
|
|
31
72
|
if (childArgs.length === 0)
|
|
32
73
|
return;
|
|
33
74
|
if (!opts.explain) {
|
|
34
75
|
log(`${pc.green("✓")} ${who.project.name} / ${label}: ${merged.applied.length} variable${merged.applied.length === 1 ? "" : "s"}`);
|
|
35
76
|
}
|
|
77
|
+
if (merged.ignored.length > 0) {
|
|
78
|
+
log(pc.yellow(`Not passed: ${merged.ignored.join(", ")}. GETMYENV_* names are the CLI's own settings.`));
|
|
79
|
+
}
|
|
36
80
|
if (merged.overridden.length > 0) {
|
|
37
81
|
log(pc.yellow(`Replaced from parent env: ${merged.overridden.join(", ")}`));
|
|
38
82
|
}
|
|
@@ -57,15 +101,20 @@ export async function runCommand(opts, childArgs) {
|
|
|
57
101
|
log(pc.dim(line));
|
|
58
102
|
const [cmd, ...args] = childArgs;
|
|
59
103
|
const child = process.platform === "win32"
|
|
60
|
-
? spawn(windowsCommandLine(childArgs
|
|
104
|
+
? spawn(windowsCommandLine(childArgs, { batch: isWindowsBatch(cmd, merged.env) }), {
|
|
105
|
+
env: merged.env,
|
|
106
|
+
stdio: "inherit",
|
|
107
|
+
shell: true,
|
|
108
|
+
})
|
|
61
109
|
: spawn(cmd, args, { env: merged.env, stdio: "inherit" });
|
|
62
110
|
// Ctrl+C already reaches the child through the terminal. Only wait for it.
|
|
63
111
|
process.on("SIGINT", () => undefined);
|
|
64
|
-
// SIGTERM / SIGHUP are sent to this process only. Pass them on.
|
|
65
|
-
|
|
112
|
+
// SIGTERM / SIGHUP (and SIGBREAK on Windows) are sent to this process only. Pass them on.
|
|
113
|
+
const stops = process.platform === "win32" ? ["SIGTERM", "SIGHUP", "SIGBREAK"] : ["SIGTERM", "SIGHUP"];
|
|
114
|
+
for (const sig of stops) {
|
|
66
115
|
process.on(sig, () => {
|
|
67
116
|
if (child.exitCode === null && child.signalCode === null)
|
|
68
|
-
child.kill(sig);
|
|
117
|
+
stopChild(child.pid, sig, () => child.kill(sig));
|
|
69
118
|
});
|
|
70
119
|
}
|
|
71
120
|
child.once("spawn", hints.markShown);
|