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.
Files changed (64) hide show
  1. package/README.md +25 -12
  2. package/dist/bin.js +8 -0
  3. package/dist/cli.js +13 -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 +19 -22
  8. package/dist/commands/guest.d.ts +1 -1
  9. package/dist/commands/guest.js +17 -6
  10. package/dist/commands/import.js +42 -10
  11. package/dist/commands/lock.js +1 -1
  12. package/dist/commands/ls.js +2 -2
  13. package/dist/commands/push.d.ts +25 -0
  14. package/dist/commands/push.js +189 -0
  15. package/dist/commands/restore.js +3 -3
  16. package/dist/commands/run.d.ts +1 -0
  17. package/dist/commands/run.js +63 -24
  18. package/dist/commands/set.d.ts +16 -0
  19. package/dist/commands/set.js +98 -14
  20. package/dist/commands/start.js +7 -7
  21. package/dist/commands/status.js +3 -3
  22. package/dist/commands/template.js +9 -6
  23. package/dist/commands/unlink.d.ts +14 -5
  24. package/dist/commands/unlink.js +79 -17
  25. package/dist/lib/api-url.d.ts +2 -0
  26. package/dist/lib/api-url.js +10 -0
  27. package/dist/lib/api.d.ts +22 -2
  28. package/dist/lib/api.js +108 -34
  29. package/dist/lib/authorize.js +2 -2
  30. package/dist/lib/bootstrap.d.ts +5 -0
  31. package/dist/lib/bootstrap.js +32 -8
  32. package/dist/lib/colors.d.ts +14 -0
  33. package/dist/lib/colors.js +17 -0
  34. package/dist/lib/confirm.d.ts +7 -3
  35. package/dist/lib/confirm.js +21 -12
  36. package/dist/lib/context.js +1 -1
  37. package/dist/lib/credentials.d.ts +4 -0
  38. package/dist/lib/credentials.js +13 -11
  39. package/dist/lib/fs.d.ts +36 -1
  40. package/dist/lib/fs.js +102 -23
  41. package/dist/lib/known-keys-cache.d.ts +15 -0
  42. package/dist/lib/known-keys-cache.js +102 -0
  43. package/dist/lib/known-keys.d.ts +2 -0
  44. package/dist/lib/known-keys.js +12 -3
  45. package/dist/lib/read-context.d.ts +26 -0
  46. package/dist/lib/read-context.js +22 -0
  47. package/dist/lib/recovery-code.js +1 -1
  48. package/dist/lib/resolve.d.ts +2 -1
  49. package/dist/lib/resolve.js +1 -1
  50. package/dist/lib/run-env.d.ts +19 -3
  51. package/dist/lib/run-env.js +66 -13
  52. package/dist/lib/template-file.js +1 -1
  53. package/dist/lib/update-check.js +1 -1
  54. package/dist/lib/upload.js +2 -2
  55. package/dist/lib/vault.js +1 -1
  56. package/dist/providers/http.d.ts +16 -0
  57. package/dist/providers/http.js +69 -0
  58. package/dist/providers/netlify.d.ts +11 -0
  59. package/dist/providers/netlify.js +184 -0
  60. package/dist/providers/types.d.ts +56 -0
  61. package/dist/providers/types.js +1 -0
  62. package/dist/providers/vercel.d.ts +17 -0
  63. package/dist/providers/vercel.js +205 -0
  64. 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.12.2):
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 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)
@@ -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
- 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.
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] display help for 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));
@@ -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,14 +1,12 @@
1
1
  import fs from "node:fs";
2
- import pc from "picocolors";
3
- import { decryptSecret, parsePayload } from "@getmyenv/crypto";
4
- import { EXPORT_WARNING, formatDotenvValue } from "@getmyenv/shared";
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
- assertVariableNames([...data.values.map((v) => v.name), ...data.missing.map((m) => m.name)]);
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 = parseEnvFile(out);
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, 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
+ }
54
50
  const ok = await confirmDiff({
55
- conflicts: [...conflicts, ...dropped],
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} variable(s) from ${ctx.slug} to ${out}`);
64
+ log(`${pc.green("✓")} Wrote ${plural(Object.keys(remote).length, "variable")} from ${ctx.slug} to ${out}`);
68
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,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import path from "node:path";
3
- import pc from "picocolors";
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
- if (readFolderToken() || (readProjectMeta()?.projectId && !opts.replaceProjectMeta)) {
20
- throw new Error("This folder is already linked. Run: npx getmyenv (or npx getmyenv unlink first)");
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
- console.error(pc.dim(`Expires in ${daysLeft} days unless claimed. ${FACTS.committedFiles}`));
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) {
@@ -1,12 +1,13 @@
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";
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 are read-only.";
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 read-only. ${FACTS.readOnlyWho}`));
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
- for (const f of target.files)
115
- 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
+ }
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: hostedOnly.map((name) => ({ name, detail: `unset in ${ctx.slug}` })),
141
- 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`,
142
174
  yes: opts.yes,
143
175
  });
144
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) {
@@ -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>;