getmyenv 0.14.1 → 1.0.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
@@ -49,7 +49,7 @@ my-app/
49
49
  ```
50
50
 
51
51
  <!-- cli:help -->
52
- Output of `npx getmyenv --help` (0.14.1):
52
+ Output of `npx getmyenv --help` (1.0.0):
53
53
 
54
54
  ```text
55
55
  Usage: getmyenv [options] [command]
@@ -1,7 +1,7 @@
1
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
- import { clearClaimFile, ensureGitignoreHasEnv, promptHidden, readClaimFile, readLine, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
4
+ import { clearClaimFile, ensureGitignoreHasEnv, promptHidden, readClaimFile, claimFileInvalid, CLAIM_FILE_INVALID, readLine, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
5
5
  import { readFolderToken, writeFolderToken } from "../lib/credentials.js";
6
6
  import { ApiClient, ApiError, resolveApiUrl } from "../lib/api.js";
7
7
  import { authorizeFolder, openBrowser, sleep } from "../lib/authorize.js";
@@ -126,6 +126,8 @@ export async function claimCommand(opts = {}) {
126
126
  if (!process.stdin.isTTY) {
127
127
  throw new Error("Claim needs a terminal. Run: npx getmyenv claim");
128
128
  }
129
+ if (claimFileInvalid())
130
+ throw new Error(CLAIM_FILE_INVALID);
129
131
  const claim = readClaimFile() ?? (await askClaimDetails());
130
132
  const apiUrl = resolveApiUrl(opts.apiUrl);
131
133
  const client = new ApiClient(apiUrl);
@@ -5,8 +5,16 @@ import { resolveApiUrl } from "../lib/api.js";
5
5
  import { clearAllCachedKeys, keychainAccount } from "../lib/keychain.js";
6
6
  /** Forget every unlocked context key on this machine. The next run asks for the Vault password. */
7
7
  export async function lockCommand(opts) {
8
- const projectId = readProjectMeta()?.projectId ?? readFolderToken()?.projectId;
9
- const fallback = projectId ? [keychainAccount(resolveApiUrl(opts.apiUrl), projectId)] : [];
8
+ // A broken project file or API URL must never stop lock from clearing the keychain.
9
+ let fallback = [];
10
+ try {
11
+ const projectId = readProjectMeta()?.projectId ?? readFolderToken()?.projectId;
12
+ if (projectId)
13
+ fallback = [keychainAccount(resolveApiUrl(opts.apiUrl), projectId)];
14
+ }
15
+ catch {
16
+ fallback = [];
17
+ }
10
18
  const removed = await clearAllCachedKeys(fallback);
11
19
  console.log(removed > 0
12
20
  ? `${pc.green("✓")} Locked. The next run asks for the Vault password.`
@@ -12,6 +12,7 @@ export declare function missingNames(keys: Array<{
12
12
  name: string;
13
13
  status: Record<string, string>;
14
14
  }>, slug: string): string[];
15
+ export declare function invalidNameMessage(name: string): string;
15
16
  export declare function parseExpires(raw: string | undefined, now?: Date): string | null | undefined;
16
17
  /** `set KEY[=VALUE] ...` Omitted values are prompted without echo. */
17
18
  export declare function setCommand(args: string[], opts: SetOpts): Promise<void>;
@@ -18,7 +18,14 @@ async function readStdinValue() {
18
18
  export function missingNames(keys, slug) {
19
19
  return keys.filter((k) => (k.status[slug] ?? "missing") === "missing").map((k) => k.name);
20
20
  }
21
- function invalidNameMessage(name) {
21
+ /** `set sk_live_...` without `KEY=` puts the value where the name goes. Never print it. */
22
+ function looksLikeValue(name) {
23
+ return name.length >= 24 || /[^A-Za-z0-9_.-]/.test(name);
24
+ }
25
+ export function invalidNameMessage(name) {
26
+ if (looksLikeValue(name)) {
27
+ return `Invalid name. It looks like a value, so it is not shown. Use KEY=VALUE, or set KEY to be prompted. ${VARIABLE_NAME_HELP}`;
28
+ }
22
29
  const hint = suggestVariableName(name);
23
30
  return `Invalid name: ${name}. ${VARIABLE_NAME_HELP}${hint ? ` Did you mean ${hint}?` : ""}`;
24
31
  }
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { pc } from "../lib/colors.js";
4
4
  import { TOKEN_ENDED_CODE } from "@getmyenv/shared";
5
- import { clearClaimFile, getLocalDir, promptConfirm, readClaimFile, readProjectMeta, } from "../lib/fs.js";
5
+ import { CLAIM_FILE_INVALID, claimFileInvalid, claimFilePath, clearClaimFile, getLocalDir, promptConfirm, readClaimFile, readProjectMeta, } from "../lib/fs.js";
6
6
  import { clearFolderToken, isTrackedByGit, readFolderToken, readFolderTokenLenient, tokenFilePath, } from "../lib/credentials.js";
7
7
  import { clearCachedKeys, keychainAccount } from "../lib/keychain.js";
8
8
  import { ApiClient, ApiError, resolveApiUrl } from "../lib/api.js";
@@ -62,7 +62,7 @@ export async function unlinkCommand(opts) {
62
62
  const label = meta?.projectName ?? meta?.projectId ?? "this folder";
63
63
  const tokenLine = !hasToken
64
64
  ? "This machine has no token for it. Removes the local .getmyenv files."
65
- : readClaimFile()
65
+ : fs.existsSync(claimFilePath())
66
66
  ? "Removes .getmyenv files except claim.json. The guest token stays, so claim still works."
67
67
  : opts.local
68
68
  ? "Removes .getmyenv files without revoking the token."
@@ -95,7 +95,7 @@ export async function unlinkCommand(opts) {
95
95
  if (!(err instanceof ApiError && (err.status === 401 || err.status === 404))) {
96
96
  throw new Error(revokeFailedMessage({
97
97
  unreachable: err instanceof ApiError && err.status === 0,
98
- guest: readClaimFile() !== null,
98
+ guest: fs.existsSync(claimFilePath()),
99
99
  }));
100
100
  }
101
101
  console.log(pc.dim("The token already no longer works on the server."));
@@ -122,6 +122,9 @@ export async function unlinkCommand(opts) {
122
122
  if (guestEnded && clearClaimFile()) {
123
123
  console.log(`${pc.green("✓")} Removed claim.json. The guest project was claimed or deleted, so it has no use.`);
124
124
  }
125
+ else if (claimFileInvalid()) {
126
+ console.log(pc.yellow(`Kept claim.json. ${CLAIM_FILE_INVALID}`));
127
+ }
125
128
  else if (readClaimFile()) {
126
129
  console.log(pc.yellow("Kept claim.json. It holds the claim code and guest passphrase. Delete it if you do not need the project."));
127
130
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { pcErr as pc } from "./colors.js";
3
- import { ensureGitignoreHasEnv, readLine, readProjectMeta, writeProjectMeta, } from "./fs.js";
3
+ import { ensureGitignoreHasEnv, promptHidden, readProjectMeta, writeProjectMeta, } from "./fs.js";
4
4
  import { writeFolderToken } from "./credentials.js";
5
5
  import { ApiClient, parseSafeUrl, resolveApiUrl } from "./api.js";
6
6
  /** Command + argv to open a URL. No shell, so the URL is never parsed as a command. */
@@ -62,7 +62,7 @@ async function saveVerifiedToken(token, apiUrl) {
62
62
  async function authorizeViaPaste(apiUrl) {
63
63
  const projectsPage = `${apiUrl.replace(/\/$/, "")}/projects`;
64
64
  console.error(pc.dim(`Create a folder token in Project settings, Tokens: ${projectsPage}\n`));
65
- const token = (await readLine("Paste the folder token: ")).trim();
65
+ const token = (await promptHidden("Paste the folder token (hidden): ")).trim();
66
66
  if (!token) {
67
67
  throw new Error("No token entered.");
68
68
  }
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { getLocalDir, getProjectRoot, readJsonFile } from "./fs.js";
5
5
  import { apiUrlForDisk } from "./api-url.js";
6
+ import { restrictToOwner } from "./private-file.js";
6
7
  /** True when git tracks this file. False without git or outside a repo. */
7
8
  export function isTrackedByGit(file, cwd = process.cwd()) {
8
9
  try {
@@ -79,7 +80,7 @@ export function writeFolderToken(entry, cwd = process.cwd()) {
79
80
  fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", {
80
81
  mode: 0o600,
81
82
  });
82
- fs.chmodSync(file, 0o600);
83
+ restrictToOwner(file);
83
84
  return file;
84
85
  }
85
86
  export function clearFolderToken(cwd = process.cwd()) {
package/dist/lib/fs.d.ts CHANGED
@@ -71,6 +71,10 @@ export type ClaimFile = {
71
71
  };
72
72
  export declare function claimFilePath(cwd?: string): string;
73
73
  export declare function readClaimFile(cwd?: string): ClaimFile | null;
74
+ /** claim.json is there but cannot be read. It may hold the only copy of the guest passphrase. */
75
+ export declare function claimFileInvalid(cwd?: string): boolean;
76
+ /** Said whenever claim.json is there but cannot be read. Never write over it. */
77
+ export declare const CLAIM_FILE_INVALID = ".getmyenv/claim.json is not valid. It may hold the only copy of the guest passphrase, so it was not changed. Fix it or move it, then run again.";
74
78
  export declare function writeClaimFile(data: Omit<ClaimFile, "version">, cwd?: string): string;
75
79
  export declare function clearClaimFile(cwd?: string): boolean;
76
80
  /** dotenv rules (multiline values, inline comments, BOM). Invalid names land in `skipped`. */
package/dist/lib/fs.js CHANGED
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import readline from "node:readline";
5
5
  import dotenv from "dotenv";
6
6
  import { CONTEXT_SLUG_RE, isValidContextSlug, isValidVariableName, LOCAL_DIR, VAULT_UNLOCK_DURATION, slugify, } from "@getmyenv/shared";
7
+ import { restrictToOwner } from "./private-file.js";
7
8
  const SLUG_RE = CONTEXT_SLUG_RE;
8
9
  /**
9
10
  * Nearest folder at or above cwd with a linked `.getmyenv/`, so commands work
@@ -110,7 +111,7 @@ export function writePrivateFile(file, content, opts) {
110
111
  if (stat && !opts.overwrite)
111
112
  throw new Error(`${file} already exists. Pick another name with -o.`);
112
113
  fs.writeFileSync(file, content, { mode: 0o600, flag: stat ? "w" : "wx" });
113
- fs.chmodSync(file, 0o600);
114
+ restrictToOwner(file);
114
115
  }
115
116
  /** OS user config for machine identity - never in the repo. */
116
117
  export function getConfigDir() {
@@ -309,6 +310,12 @@ export function readClaimFile(cwd = process.cwd()) {
309
310
  return null;
310
311
  }
311
312
  }
313
+ /** claim.json is there but cannot be read. It may hold the only copy of the guest passphrase. */
314
+ export function claimFileInvalid(cwd = process.cwd()) {
315
+ return fs.existsSync(claimFilePath(cwd)) && readClaimFile(cwd) === null;
316
+ }
317
+ /** Said whenever claim.json is there but cannot be read. Never write over it. */
318
+ export const CLAIM_FILE_INVALID = ".getmyenv/claim.json is not valid. It may hold the only copy of the guest passphrase, so it was not changed. Fix it or move it, then run again.";
312
319
  export function writeClaimFile(data, cwd = process.cwd()) {
313
320
  const file = claimFilePath(cwd);
314
321
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
@@ -316,7 +323,7 @@ export function writeClaimFile(data, cwd = process.cwd()) {
316
323
  fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n", {
317
324
  mode: 0o600,
318
325
  });
319
- fs.chmodSync(file, 0o600);
326
+ restrictToOwner(file);
320
327
  return file;
321
328
  }
322
329
  export function clearClaimFile(cwd = process.cwd()) {
@@ -1,5 +1,5 @@
1
1
  import { VAULT_PASSWORD_HELP, validateVaultPassword } from "@getmyenv/shared";
2
- import { promptHidden, readClaimFile } from "./fs.js";
2
+ import { CLAIM_FILE_INVALID, claimFileInvalid, promptHidden, readClaimFile } from "./fs.js";
3
3
  /** Ask for a new Vault password twice. Same rule as the dashboard. */
4
4
  export async function promptNewVaultPassword() {
5
5
  if (process.stdin.isTTY)
@@ -26,6 +26,8 @@ export async function resolveVaultPassword(opts) {
26
26
  if (claim?.passphrase) {
27
27
  return claim.passphrase;
28
28
  }
29
+ if (claimFileInvalid())
30
+ throw new Error(CLAIM_FILE_INVALID);
29
31
  throw new Error("Guest project is missing .getmyenv/claim.json. Run it from the folder that created the project, or run: npx getmyenv claim");
30
32
  }
31
33
  if (opts.create)
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { generateMachineIdentity, signAccessRequest, } from "@getmyenv/crypto";
5
5
  import { machineProofMessage } from "@getmyenv/shared";
6
6
  import { getConfigDir } from "./fs.js";
7
+ import { restrictToOwner } from "./private-file.js";
7
8
  /**
8
9
  * Machine identity per project, stored in the OS user config (never the repo).
9
10
  * It identifies this machine for read-only contexts. It does not decrypt values.
@@ -25,6 +26,8 @@ export function loadOrCreateIdentity(projectId) {
25
26
  }
26
27
  const identity = generateMachineIdentity();
27
28
  fs.writeFileSync(file, JSON.stringify(identity, null, 2), { mode: 0o600 });
29
+ // A rewritten broken file keeps its old mode otherwise.
30
+ restrictToOwner(file);
28
31
  return identity;
29
32
  }
30
33
  export function machineLabel() {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Only this user can read the file. On macOS and Linux that is mode 0600.
3
+ * Windows ignores the mode, so the file would inherit the folder's rules,
4
+ * which outside the user profile let every local user read it. There we grant
5
+ * this user full control, then drop the inherited rules. Best effort: a
6
+ * failure leaves the file as it was.
7
+ */
8
+ export declare function restrictToOwner(file: string): void;
@@ -0,0 +1,27 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ /**
5
+ * Only this user can read the file. On macOS and Linux that is mode 0600.
6
+ * Windows ignores the mode, so the file would inherit the folder's rules,
7
+ * which outside the user profile let every local user read it. There we grant
8
+ * this user full control, then drop the inherited rules. Best effort: a
9
+ * failure leaves the file as it was.
10
+ */
11
+ export function restrictToOwner(file) {
12
+ fs.chmodSync(file, 0o600);
13
+ if (process.platform !== "win32")
14
+ return;
15
+ try {
16
+ const name = os.userInfo().username;
17
+ const domain = process.env.USERDOMAIN;
18
+ const user = domain ? `${domain}\\${name}` : name;
19
+ const opts = { stdio: "ignore", windowsHide: true, timeout: 5000 };
20
+ // Grant first: if that fails, the inherited rules stay and the file stays readable to us.
21
+ execFileSync("icacls", [file, "/grant:r", `${user}:F`], opts);
22
+ execFileSync("icacls", [file, "/inheritance:r"], opts);
23
+ }
24
+ catch {
25
+ /* keep the inherited rules */
26
+ }
27
+ }
@@ -99,7 +99,7 @@ async function fetchAndCache(file, timeoutMs) {
99
99
  const data = (await res.json());
100
100
  if (!data.version)
101
101
  return;
102
- fs.mkdirSync(path.dirname(file), { recursive: true });
102
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
103
103
  fs.writeFileSync(file, JSON.stringify({ latest: data.version, checkedAt: new Date().toISOString() }), {
104
104
  mode: 0o600,
105
105
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getmyenv",
3
- "version": "0.14.1",
3
+ "version": "1.0.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": {