getmyenv 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,9 @@ npx getmyenv run -- npm run dev # start with the default context
12
12
  npx getmyenv run staging -- npm start # or pick one
13
13
  ```
14
14
 
15
+ - Run getmyenv where your app starts: your machine, the server, the CI job, or the container's start command. It needs Node 24 or newer there. run is the default. If getmyenv can't run where the app starts, export writes a plaintext .env instead.
16
+ - run fetches the values from getmyenv each time it starts. If it can't reach the server, the command does not start. A running process keeps its values.
17
+
15
18
  ## Commands
16
19
 
17
20
  - `getmyenv`: setup in a new folder, status in a linked one. Without a TTY it prints next steps and exits.
@@ -34,8 +37,19 @@ Prompts and status lines go to stderr, so `export --stdout > .env` stays clean.
34
37
 
35
38
  Commit `.getmyenv/project.json` and `.getmyenv/template.json`. `token.json` and `claim.json` stay local and are gitignored. `template.json` lists the variable names, never values. In a fresh clone, `npx getmyenv` can start a new project from it and asks for each value.
36
39
 
40
+ What the CLI writes. Not every folder has all four:
41
+
42
+ ```text
43
+ my-app/
44
+ .getmyenv/
45
+ project.json committed. Project id, name and the folder's default context. Written by setup, guest or sign-in.
46
+ template.json committed. Variable names, never values. Written once the project has a variable name.
47
+ token.json gitignored. The folder token. Written by sign-in or guest. Not used with GETMYENV_TOKEN.
48
+ claim.json gitignored, guest only. Claim code and guest passphrase. Keep it private. Written by guest. Deleted by claim.
49
+ ```
50
+
37
51
  <!-- cli:help -->
38
- Output of `npx getmyenv --help` (0.11.0):
52
+ Output of `npx getmyenv --help` (0.12.0):
39
53
 
40
54
  ```text
41
55
  Usage: getmyenv [options] [command]
@@ -88,7 +102,7 @@ Environment:
88
102
 
89
103
  ## Vault password
90
104
 
91
- Each context has its own key, sealed to your account. Your Vault password opens your account key. For open contexts, after you enter the Vault password in a terminal, the unlocked context keys stay in your OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) for 8 hours. The password is never stored.
105
+ One Vault password per account unlocks your private key on your device. Each context has its own key, and each reader gets their own sealed copy. Forgot the Vault password? Your recovery code sets a new one in the dashboard. For open contexts, after you enter the Vault password in a terminal, the unlocked context keys stay in your OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) for 8 hours. The password is never stored.
92
106
 
93
107
  - `npx getmyenv lock` removes every entry. `unlink` removes this project's.
94
108
  - Server tokens hold their context key and never ask.
@@ -33,6 +33,6 @@ export async function backupCommand(opts) {
33
33
  writePrivateFile(out, serializeBackup(envelope), { overwrite: false });
34
34
  console.log(`${pc.green("✓")} Encrypted backup: ${out} (${data.values.length} values, ${data.contexts.length} open contexts)`);
35
35
  console.log(pc.dim(data.recoveryWrappedKey
36
- ? "It opens with your current Vault password or recovery code. Read-only contexts are backed up from the dashboard."
37
- : "It opens with your current Vault password. Read-only contexts are backed up from the dashboard."));
36
+ ? "It opens with your Vault password or recovery code as they are now, not with newer ones. Read-only contexts are backed up from the dashboard."
37
+ : "It opens with your Vault password as it is now, not with a newer one. Read-only contexts are backed up from the dashboard."));
38
38
  }
@@ -166,7 +166,7 @@ export async function importCommand(file, opts) {
166
166
  }
167
167
  ensureGitignoreHasEnv();
168
168
  if (imported > 0) {
169
- console.error(pc.dim("Your .env files are unchanged. getmyenv run does not need them. Delete them when you are ready.\n"));
169
+ console.error(pc.dim(`Your .env files are unchanged. ${FACTS.runIgnoresEnvFile}\n`));
170
170
  if (!opts.noRunLine) {
171
171
  console.error("Start your app with:");
172
172
  console.error(pc.cyan(` ${runLine(explicitContext ?? defaultContextSlug(who, meta))}\n`));
@@ -4,6 +4,7 @@ import pc from "picocolors";
4
4
  import { decryptSecret, parsePayload } from "@getmyenv/crypto";
5
5
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
6
6
  import { resolveContext } from "../lib/context.js";
7
+ import { pendingFirstRunHints } from "../lib/first-run.js";
7
8
  import { resolveValues } from "../lib/resolve.js";
8
9
  import { contextKey } from "../lib/vault.js";
9
10
  import { assertVariableNames, explainLines, mergeEnv, runContextLabel, signalExitCode, validationError, windowsCommandLine, } from "../lib/run-env.js";
@@ -51,6 +52,19 @@ export async function runCommand(opts, childArgs) {
51
52
  if (merged.unresolved.length > 0) {
52
53
  log(pc.yellow(`Not set: ${merged.unresolved.map((m) => m.name).join(", ")}`));
53
54
  }
55
+ const hints = opts.explain
56
+ ? { lines: [], markShown: () => undefined }
57
+ : pendingFirstRunHints({
58
+ isTTY: Boolean(process.stderr.isTTY),
59
+ env: process.env,
60
+ tokenKind: who.token.kind,
61
+ apiUrl: linked.apiUrl,
62
+ projectId: who.project.id,
63
+ childArgs,
64
+ cwd: process.cwd(),
65
+ });
66
+ for (const line of hints.lines)
67
+ log(pc.dim(line));
54
68
  const [cmd, ...args] = childArgs;
55
69
  const child = process.platform === "win32"
56
70
  ? spawn(windowsCommandLine(childArgs), { env: merged.env, stdio: "inherit", shell: true })
@@ -64,6 +78,7 @@ export async function runCommand(opts, childArgs) {
64
78
  child.kill(sig);
65
79
  });
66
80
  }
81
+ child.once("spawn", hints.markShown);
67
82
  const code = await new Promise((resolve) => {
68
83
  child.on("error", (err) => {
69
84
  log(pc.red(`Could not start ${cmd}: ${err.message}`));
@@ -0,0 +1,24 @@
1
+ export type HintContext = {
2
+ isTTY: boolean;
3
+ env: NodeJS.ProcessEnv;
4
+ tokenKind: "folder" | "guest" | "server";
5
+ };
6
+ export declare function hintsEnabled({ isTTY, env, tokenKind }: HintContext): boolean;
7
+ /** The package.json script a command like `npm start` or `pnpm run dev` starts, if any. */
8
+ export declare function packageScriptName(childArgs: string[]): string | null;
9
+ /** True when that script passes --env-file to node. */
10
+ export declare function scriptUsesEnvFile(childArgs: string[], packageJson: unknown): boolean;
11
+ export declare function hintLines(childArgs: string[], cwd: string): string[];
12
+ /** One marker per server origin and project, in the CLI config dir. */
13
+ export declare function hintMarkerPath(configDir: string, apiUrl: string, projectId: string): string;
14
+ export type PendingHints = {
15
+ lines: string[];
16
+ markShown: () => void;
17
+ };
18
+ export declare function pendingFirstRunHints(input: HintContext & {
19
+ apiUrl: string;
20
+ projectId: string;
21
+ childArgs: string[];
22
+ cwd: string;
23
+ configDir?: () => string;
24
+ }): PendingHints;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Hints printed by the first `run` of a project on this machine. Never in CI,
3
+ * with GETMYENV_TOKEN or a server token, or when stderr is not a terminal.
4
+ * The marker is written once the child has spawned, so a run that fails before
5
+ * that shows the hints again. Marker errors never change what `run` does.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { FACTS } from "@getmyenv/shared";
11
+ import { getConfigDir } from "./fs.js";
12
+ import { keychainAccount } from "./keychain.js";
13
+ export function hintsEnabled({ isTTY, env, tokenKind }) {
14
+ if (!isTTY || tokenKind === "server" || env.GETMYENV_TOKEN?.trim())
15
+ return false;
16
+ const ci = env.CI?.trim().toLowerCase();
17
+ return !ci || ci === "false" || ci === "0";
18
+ }
19
+ const MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
20
+ /** The package.json script a command like `npm start` or `pnpm run dev` starts, if any. */
21
+ export function packageScriptName(childArgs) {
22
+ const [cmd, ...rest] = childArgs;
23
+ if (!cmd)
24
+ return null;
25
+ const manager = path.basename(cmd).replace(/\.(cmd|exe|ps1)$/i, "").toLowerCase();
26
+ if (!MANAGERS.has(manager))
27
+ return null;
28
+ const args = rest.filter((a) => !a.startsWith("-"));
29
+ if (args[0] === "run" || args[0] === "run-script")
30
+ return args[1] ?? null;
31
+ if (manager === "npm")
32
+ return args[0] === "start" || args[0] === "test" ? args[0] : null;
33
+ return args[0] ?? null;
34
+ }
35
+ /** True when that script passes --env-file to node. */
36
+ export function scriptUsesEnvFile(childArgs, packageJson) {
37
+ const name = packageScriptName(childArgs);
38
+ if (!name)
39
+ return false;
40
+ const scripts = packageJson?.scripts;
41
+ const script = scripts?.[name];
42
+ return typeof script === "string" && /--env-file\b/.test(script);
43
+ }
44
+ function readPackageJson(cwd) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ export function hintLines(childArgs, cwd) {
53
+ const lines = [FACTS.runExplainHint];
54
+ if (fs.existsSync(path.join(cwd, ".env")))
55
+ lines.push(FACTS.runIgnoresEnvFile);
56
+ if (scriptUsesEnvFile(childArgs, readPackageJson(cwd)))
57
+ lines.push(FACTS.envFileScripts);
58
+ return lines;
59
+ }
60
+ /** One marker per server origin and project, in the CLI config dir. */
61
+ export function hintMarkerPath(configDir, apiUrl, projectId) {
62
+ const id = createHash("sha256").update(keychainAccount(apiUrl, projectId)).digest("hex").slice(0, 32);
63
+ return path.join(configDir, "hints", `${id}.json`);
64
+ }
65
+ const NONE = { lines: [], markShown: () => undefined };
66
+ export function pendingFirstRunHints(input) {
67
+ if (!hintsEnabled(input))
68
+ return NONE;
69
+ let marker = null;
70
+ try {
71
+ marker = hintMarkerPath((input.configDir ?? getConfigDir)(), input.apiUrl, input.projectId);
72
+ if (fs.existsSync(marker))
73
+ return NONE;
74
+ }
75
+ catch {
76
+ marker = null;
77
+ }
78
+ return {
79
+ lines: hintLines(input.childArgs, input.cwd),
80
+ markShown: () => {
81
+ if (!marker)
82
+ return;
83
+ try {
84
+ fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
85
+ fs.writeFileSync(marker, `${JSON.stringify({ shownAt: new Date().toISOString() })}\n`, { mode: 0o600 });
86
+ }
87
+ catch {
88
+ /* the hints show again next time */
89
+ }
90
+ },
91
+ };
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getmyenv",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI for getmyenv, an encrypted store for environment variables and secrets. We store ciphertext only.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@getmyenv/crypto": "0.5.0",
37
- "@getmyenv/shared": "0.10.0",
37
+ "@getmyenv/shared": "0.11.0",
38
38
  "@napi-rs/keyring": "^2.1.0",
39
39
  "commander": "^15.0.0",
40
40
  "dotenv": "^16.4.7",