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.
Files changed (54) hide show
  1. package/README.md +14 -12
  2. package/dist/bin.js +8 -0
  3. package/dist/cli.js +11 -3
  4. package/dist/commands/backup.js +1 -1
  5. package/dist/commands/claim.js +3 -2
  6. package/dist/commands/context.js +4 -4
  7. package/dist/commands/export.js +17 -11
  8. package/dist/commands/guest.d.ts +1 -1
  9. package/dist/commands/guest.js +19 -6
  10. package/dist/commands/import.js +38 -9
  11. package/dist/commands/lock.js +1 -1
  12. package/dist/commands/ls.js +2 -2
  13. package/dist/commands/push.js +6 -5
  14. package/dist/commands/restore.js +3 -3
  15. package/dist/commands/run.d.ts +1 -0
  16. package/dist/commands/run.js +61 -12
  17. package/dist/commands/set.d.ts +16 -0
  18. package/dist/commands/set.js +91 -12
  19. package/dist/commands/start.js +7 -7
  20. package/dist/commands/status.js +3 -3
  21. package/dist/commands/template.js +3 -3
  22. package/dist/commands/unlink.d.ts +14 -5
  23. package/dist/commands/unlink.js +79 -17
  24. package/dist/lib/api-url.d.ts +2 -0
  25. package/dist/lib/api-url.js +10 -0
  26. package/dist/lib/api.d.ts +22 -2
  27. package/dist/lib/api.js +108 -34
  28. package/dist/lib/authorize.js +2 -2
  29. package/dist/lib/bootstrap.d.ts +5 -0
  30. package/dist/lib/bootstrap.js +32 -8
  31. package/dist/lib/colors.d.ts +14 -0
  32. package/dist/lib/colors.js +17 -0
  33. package/dist/lib/confirm.d.ts +7 -3
  34. package/dist/lib/confirm.js +21 -12
  35. package/dist/lib/context.js +1 -1
  36. package/dist/lib/credentials.d.ts +4 -0
  37. package/dist/lib/credentials.js +13 -11
  38. package/dist/lib/fs.d.ts +36 -1
  39. package/dist/lib/fs.js +107 -28
  40. package/dist/lib/known-keys-cache.d.ts +3 -2
  41. package/dist/lib/known-keys-cache.js +19 -5
  42. package/dist/lib/known-keys.js +1 -1
  43. package/dist/lib/recovery-code.js +1 -1
  44. package/dist/lib/resolve.js +1 -1
  45. package/dist/lib/run-env.d.ts +19 -3
  46. package/dist/lib/run-env.js +66 -13
  47. package/dist/lib/template-file.js +1 -1
  48. package/dist/lib/update-check.js +1 -1
  49. package/dist/lib/upload.js +2 -2
  50. package/dist/lib/vault.js +1 -1
  51. package/dist/providers/http.js +15 -12
  52. package/dist/providers/netlify.js +3 -1
  53. package/dist/providers/vercel.js +3 -1
  54. 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 works for 30 days. Claim it within 60.
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]`, `help [command]`
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.13.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 output the version number
60
+ -V, --version Print the version
61
61
  --api-url <url> API base URL
62
- -h, --help display help for command
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] <pairs...> Set values: set KEY=VALUE, or set KEY to be
72
- prompted
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
- works for 30 days. Claim it within 60.
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] display help for 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) => {
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import pc from "picocolors";
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";
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
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
- const claimUrl = `${base}/claim?code=${encodeURIComponent(claim.claimCode)}&session=${encodeURIComponent(session.sessionId)}`;
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`);
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
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("read-only") : pc.dim("open ");
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 read-only. Copy from it in the dashboard.`);
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 read-only. This folder's token reads open contexts only. Read-only contexts are read on servers with a server token.`);
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 });
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
- import pc from "picocolors";
3
- import { EXPORT_WARNING, formatDotenvValue } from "@getmyenv/shared";
4
- 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";
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 = parseEnvFile(out);
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, will be removed" }));
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: [...conflicts, ...dropped],
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} variable(s) from ${ctx.slug} to ${out}`);
64
+ log(`${pc.green("✓")} Wrote ${plural(Object.keys(remote).length, "variable")} from ${ctx.slug} to ${out}`);
59
65
  }
@@ -3,7 +3,7 @@ export type GuestOpts = {
3
3
  apiUrl?: string;
4
4
  };
5
5
  type CreateGuestOpts = GuestOpts & {
6
- /** New project from the template file: project.json may point to another project, and is replaced once this one exists. */
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;
@@ -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 "picocolors";
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
- if (readFolderToken() || (readProjectMeta()?.projectId && !opts.replaceProjectMeta)) {
20
- throw new Error("This folder is already linked. Run: npx getmyenv (or npx getmyenv unlink first)");
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
- console.error(pc.dim(`Expires in ${daysLeft} days unless claimed. ${FACTS.committedFiles}`));
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) {
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import pc from "picocolors";
4
- import { FACTS } from "@getmyenv/shared";
5
- import { contextSlugFromEnvFile, ensureGitignoreHasEnv, parseEnvFile, promptConfirm, rootEnvFiles, } from "../lib/fs.js";
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 are read-only.";
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 read-only. ${FACTS.readOnlyWho}`));
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
- for (const f of target.files)
117
- Object.assign(pairs, parseEnvFile(f));
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: hostedOnly.map((name) => ({ name, detail: `unset in ${ctx.slug}` })),
144
- action: `Unset ${hostedOnly.length} value(s) not in the file`,
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) {
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { pc } from "../lib/colors.js";
2
2
  import { readProjectMeta } from "../lib/fs.js";
3
3
  import { readFolderToken } from "../lib/credentials.js";
4
4
  import { resolveApiUrl } from "../lib/api.js";
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
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} (read-only)` : 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) {
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
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();
@@ -1,5 +1,5 @@
1
1
  import fs from "node:fs";
2
- import pc from "picocolors";
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(" (read-only, skipped)")
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 read-only contexts in the dashboard.`));
108
+ console.log(pc.yellow(`Skipped: ${skipped.join(", ")}. Restore Read-only contexts in the dashboard.`));
109
109
  }
110
110
  }
@@ -4,6 +4,7 @@ export type RunOpts = {
4
4
  explain?: boolean;
5
5
  keepExisting?: boolean;
6
6
  allowMissing?: boolean;
7
+ allowUnsafeEnv?: boolean;
7
8
  };
8
9
  /** Resolve, validate, merge, spawn. */
9
10
  export declare function runCommand(opts: RunOpts, childArgs: string[]): Promise<void>;
@@ -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 "picocolors";
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 { explainLines, mergeEnv, runContextLabel, signalExitCode, validationError, windowsCommandLine, } from "../lib/run-env.js";
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 ensureTokenAndLink({ apiUrl: opts.apiUrl });
17
- const { who, meta } = linked;
18
- const ctx = resolveContext(who, meta, opts.context);
19
- const res = await readContextValues(linked, ctx.slug, "run");
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), { env: merged.env, stdio: "inherit", shell: true })
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
- for (const sig of ["SIGTERM", "SIGHUP"]) {
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);