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
@@ -1,25 +1,34 @@
1
- import pc from "picocolors";
1
+ import { plural } from "@getmyenv/shared";
2
+ import { pcErr as pc } from "./colors.js";
2
3
  import { promptConfirm } from "./fs.js";
4
+ function printItems(title, items) {
5
+ if (items.length === 0)
6
+ return;
7
+ console.error(pc.yellow(`\n${title}:`));
8
+ for (const c of items.slice(0, 20)) {
9
+ console.error(` ${c.name}${c.detail ? pc.dim(` - ${c.detail}`) : ""}`);
10
+ }
11
+ if (items.length > 20) {
12
+ console.error(pc.dim(` …and ${items.length - 20} more`));
13
+ }
14
+ }
3
15
  /**
4
- * Prompt only when there are conflicts. Returns true to proceed.
5
- * Non-TTY: requires `yes` to be true when conflicts exist.
16
+ * Prompt only when something would be lost. Returns true to proceed.
17
+ * `conflicts` are names whose value changes, `removed` names that go away.
18
+ * Each list is counted on its own. Non-TTY: requires `yes`.
6
19
  */
7
20
  export async function confirmDiff(opts) {
8
21
  const { conflicts, action, yes } = opts;
9
- if (conflicts.length === 0)
22
+ const removed = opts.removed ?? [];
23
+ if (conflicts.length === 0 && removed.length === 0)
10
24
  return true;
11
- console.error(pc.yellow(`\n${conflicts.length} conflict(s):`));
12
- for (const c of conflicts.slice(0, 20)) {
13
- console.error(` ${c.name}${c.detail ? pc.dim(` - ${c.detail}`) : ""}`);
14
- }
15
- if (conflicts.length > 20) {
16
- console.error(pc.dim(` …and ${conflicts.length - 20} more`));
17
- }
25
+ printItems(plural(conflicts.length, "conflict"), conflicts);
26
+ printItems(`${plural(removed.length, "name")} ${opts.removedLabel ?? "will be removed"}`, removed);
18
27
  console.error("");
19
28
  if (yes)
20
29
  return true;
21
30
  if (!process.stdin.isTTY) {
22
- throw new Error(`Conflicts require confirmation. Re-run with --yes to ${action.toLowerCase()}.`);
31
+ throw new Error(`${conflicts.length > 0 ? "Conflicts require" : "Removing them requires"} confirmation. Re-run with --yes to ${action.toLowerCase()}.`);
23
32
  }
24
33
  return promptConfirm(`${action}?`, { defaultYes: false });
25
34
  }
@@ -35,7 +35,7 @@ export function resolveContext(who, meta, explicit) {
35
35
  if (found)
36
36
  return found;
37
37
  if (who.token.kind === "server") {
38
- throw new Error(`This server token reads ${who.token.context ?? "one read-only context"} only.`);
38
+ throw new Error(`This server token reads ${who.token.context ?? "one Read-only context"} only.`);
39
39
  }
40
40
  const available = who.contexts.map((c) => c.slug).join(", ") || "none";
41
41
  const next = who.isGuest
@@ -15,7 +15,11 @@ export type FolderToken = {
15
15
  addedAt: string;
16
16
  };
17
17
  export declare function tokenFilePath(cwd?: string): string;
18
+ export declare const TOKEN_JSON_INVALID = ".getmyenv/token.json is not valid JSON. Fix it, or run: npx getmyenv unlink --yes, then npx getmyenv";
19
+ /** Null when there is no token.json. Throws TOKEN_JSON_INVALID when it is not JSON. */
18
20
  export declare function readFolderToken(cwd?: string): FolderToken | null;
21
+ /** `readFolderToken`, with an invalid token.json read as none. For unlink and the API URL only. */
22
+ export declare function readFolderTokenLenient(cwd?: string): FolderToken | null;
19
23
  export declare function writeFolderToken(entry: {
20
24
  token: string;
21
25
  apiUrl?: string;
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { getLocalDir, getProjectRoot } from "./fs.js";
4
+ import { getLocalDir, getProjectRoot, readJsonFile } from "./fs.js";
5
5
  import { apiUrlForDisk } from "./api-url.js";
6
6
  /** True when git tracks this file. False without git or outside a repo. */
7
7
  export function isTrackedByGit(file, cwd = process.cwd()) {
@@ -31,17 +31,10 @@ export function assertTokenNotCommitted(cwd = process.cwd()) {
31
31
  export function tokenFilePath(cwd = process.cwd()) {
32
32
  return path.join(getLocalDir(cwd), "token.json");
33
33
  }
34
+ export const TOKEN_JSON_INVALID = ".getmyenv/token.json is not valid JSON. Fix it, or run: npx getmyenv unlink --yes, then npx getmyenv";
35
+ /** Null when there is no token.json. Throws TOKEN_JSON_INVALID when it is not JSON. */
34
36
  export function readFolderToken(cwd = process.cwd()) {
35
- const file = tokenFilePath(cwd);
36
- if (!fs.existsSync(file))
37
- return null;
38
- let raw;
39
- try {
40
- raw = JSON.parse(fs.readFileSync(file, "utf8"));
41
- }
42
- catch {
43
- return null;
44
- }
37
+ const raw = readJsonFile(tokenFilePath(cwd), TOKEN_JSON_INVALID);
45
38
  if (!raw || typeof raw !== "object")
46
39
  return null;
47
40
  const d = raw;
@@ -63,6 +56,15 @@ export function readFolderToken(cwd = process.cwd()) {
63
56
  addedAt: d.addedAt,
64
57
  };
65
58
  }
59
+ /** `readFolderToken`, with an invalid token.json read as none. For unlink and the API URL only. */
60
+ export function readFolderTokenLenient(cwd = process.cwd()) {
61
+ try {
62
+ return readFolderToken(cwd);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
66
68
  export function writeFolderToken(entry, cwd = process.cwd()) {
67
69
  const file = tokenFilePath(cwd);
68
70
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
package/dist/lib/fs.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  /**
2
3
  * `.getmyenv/project.json`, committed so a clone knows its project.
3
4
  * Holds no API URL: a committed file must not redirect where tokens go.
@@ -15,8 +16,22 @@ export type ProjectMeta = {
15
16
  */
16
17
  export declare function getProjectRoot(cwd?: string): string;
17
18
  export declare function getLocalDir(cwd?: string): string;
19
+ /** Text of a file as UTF-8, without a byte order mark (Notepad and PowerShell 5.1 add one). */
20
+ export declare function readTextFile(file: string): string;
21
+ /**
22
+ * Parse a JSON file. Null when it does not exist. Invalid JSON throws
23
+ * `invalid`: a broken file is never read as missing, so it is never
24
+ * silently replaced.
25
+ */
26
+ export declare function readJsonFile(file: string, invalid: string): unknown;
27
+ export declare const PROJECT_JSON_INVALID = ".getmyenv/project.json is not valid JSON. Fix it, or restore it: git checkout -- .getmyenv/project.json";
18
28
  export declare function readProjectMeta(cwd?: string): ProjectMeta | null;
19
29
  export declare function writeProjectMeta(meta: ProjectMeta, cwd?: string): void;
30
+ /**
31
+ * Check that `file` can be written as a plain file: not a symlink, not a
32
+ * folder, and its folder exists. Returns its stats, or null when it is new.
33
+ */
34
+ export declare function assertFileTarget(file: string): fs.Stats | null;
20
35
  /**
21
36
  * Write a file only this user can read. Never through a symlink. A new file
22
37
  * is created with `wx`; an existing one is replaced only when `overwrite`.
@@ -58,8 +73,28 @@ export declare function claimFilePath(cwd?: string): string;
58
73
  export declare function readClaimFile(cwd?: string): ClaimFile | null;
59
74
  export declare function writeClaimFile(data: Omit<ClaimFile, "version">, cwd?: string): string;
60
75
  export declare function clearClaimFile(cwd?: string): boolean;
61
- /** dotenv rules (multiline values, inline comments, BOM), valid names only. */
76
+ /** dotenv rules (multiline values, inline comments, BOM). Invalid names land in `skipped`. */
77
+ export declare function readEnvText(text: string): {
78
+ values: Record<string, string>;
79
+ skipped: string[];
80
+ };
81
+ /** `readEnvText`, valid names only. */
62
82
  export declare function parseEnvText(text: string): Record<string, string>;
83
+ /**
84
+ * Text from bytes, by byte order mark: UTF-16 LE or BE (PowerShell 5.1 `>`
85
+ * writes UTF-16 LE), else UTF-8 without its BOM.
86
+ */
87
+ export declare function decodeText(buf: Buffer): string;
88
+ /** `readEnvText` for a file, UTF-8 or UTF-16. A missing file reads as empty. */
89
+ export declare function readEnvFile(filePath: string): {
90
+ values: Record<string, string>;
91
+ skipped: string[];
92
+ };
93
+ /** Names that appear more than once in an env file, with their count. dotenv keeps the last one. */
94
+ export declare function duplicateEnvNames(filePath: string): Array<{
95
+ name: string;
96
+ count: number;
97
+ }>;
63
98
  /** `parseEnvText` for a file. A missing file reads as empty. */
64
99
  export declare function parseEnvFile(filePath: string): Record<string, string>;
65
100
  /** `.example`, `.sample` and `.template` files hold placeholders, not values. */
package/dist/lib/fs.js CHANGED
@@ -27,17 +27,29 @@ export function getProjectRoot(cwd = process.cwd()) {
27
27
  export function getLocalDir(cwd = process.cwd()) {
28
28
  return path.join(getProjectRoot(cwd), LOCAL_DIR);
29
29
  }
30
- export function readProjectMeta(cwd = process.cwd()) {
31
- const file = path.join(getLocalDir(cwd), "project.json");
30
+ /** Text of a file as UTF-8, without a byte order mark (Notepad and PowerShell 5.1 add one). */
31
+ export function readTextFile(file) {
32
+ return fs.readFileSync(file, "utf8").replace(/^/, "");
33
+ }
34
+ /**
35
+ * Parse a JSON file. Null when it does not exist. Invalid JSON throws
36
+ * `invalid`: a broken file is never read as missing, so it is never
37
+ * silently replaced.
38
+ */
39
+ export function readJsonFile(file, invalid) {
32
40
  if (!fs.existsSync(file))
33
41
  return null;
34
- let raw;
35
42
  try {
36
- raw = JSON.parse(fs.readFileSync(file, "utf8"));
43
+ return JSON.parse(readTextFile(file));
37
44
  }
38
45
  catch {
39
- return null;
46
+ throw new Error(invalid);
40
47
  }
48
+ }
49
+ export const PROJECT_JSON_INVALID = ".getmyenv/project.json is not valid JSON. Fix it, or restore it: git checkout -- .getmyenv/project.json";
50
+ export function readProjectMeta(cwd = process.cwd()) {
51
+ const file = path.join(getLocalDir(cwd), "project.json");
52
+ const raw = readJsonFile(file, PROJECT_JSON_INVALID);
41
53
  if (!raw || typeof raw !== "object")
42
54
  return null;
43
55
  const d = raw;
@@ -69,21 +81,32 @@ export function writeProjectMeta(meta, cwd = process.cwd()) {
69
81
  fs.writeFileSync(path.join(dir, "project.json"), JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
70
82
  }
71
83
  /**
72
- * Write a file only this user can read. Never through a symlink. A new file
73
- * is created with `wx`; an existing one is replaced only when `overwrite`.
84
+ * Check that `file` can be written as a plain file: not a symlink, not a
85
+ * folder, and its folder exists. Returns its stats, or null when it is new.
74
86
  */
75
- export function writePrivateFile(file, content, opts) {
76
- let stat = null;
77
- try {
78
- stat = fs.lstatSync(file);
79
- }
80
- catch {
81
- /* does not exist */
82
- }
87
+ export function assertFileTarget(file) {
88
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false }) ?? null;
83
89
  if (stat?.isSymbolicLink())
84
90
  throw new Error(`${file} is a symlink. Refusing to write through it.`);
91
+ if (stat?.isDirectory()) {
92
+ throw new Error(`${file} is a folder. Pass a file name with -o.`);
93
+ }
85
94
  if (stat && !stat.isFile())
86
95
  throw new Error(`${file} is not a regular file.`);
96
+ if (!stat) {
97
+ const dir = path.dirname(path.resolve(file));
98
+ if (!fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
99
+ throw new Error(`The folder ${path.dirname(file)} does not exist. Create it first, or pick another path with -o.`);
100
+ }
101
+ }
102
+ return stat;
103
+ }
104
+ /**
105
+ * Write a file only this user can read. Never through a symlink. A new file
106
+ * is created with `wx`; an existing one is replaced only when `overwrite`.
107
+ */
108
+ export function writePrivateFile(file, content, opts) {
109
+ const stat = assertFileTarget(file);
87
110
  if (stat && !opts.overwrite)
88
111
  throw new Error(`${file} already exists. Pick another name with -o.`);
89
112
  fs.writeFileSync(file, content, { mode: 0o600, flag: stat ? "w" : "wx" });
@@ -265,7 +288,7 @@ export function readClaimFile(cwd = process.cwd()) {
265
288
  if (!fs.existsSync(file))
266
289
  return null;
267
290
  try {
268
- const raw = JSON.parse(fs.readFileSync(file, "utf8"));
291
+ const raw = JSON.parse(readTextFile(file));
269
292
  if (typeof raw.claimCode !== "string" ||
270
293
  typeof raw.passphrase !== "string" ||
271
294
  typeof raw.projectId !== "string") {
@@ -303,20 +326,76 @@ export function clearClaimFile(cwd = process.cwd()) {
303
326
  fs.unlinkSync(file);
304
327
  return true;
305
328
  }
306
- /** dotenv rules (multiline values, inline comments, BOM), valid names only. */
307
- export function parseEnvText(text) {
308
- const out = {};
329
+ /** dotenv rules (multiline values, inline comments, BOM). Invalid names land in `skipped`. */
330
+ export function readEnvText(text) {
331
+ const values = {};
332
+ const skipped = [];
309
333
  for (const [key, value] of Object.entries(dotenv.parse(text))) {
310
334
  if (isValidVariableName(key))
311
- out[key] = value;
335
+ values[key] = value;
336
+ else
337
+ skipped.push(key);
338
+ }
339
+ return { values, skipped };
340
+ }
341
+ /** `readEnvText`, valid names only. */
342
+ export function parseEnvText(text) {
343
+ return readEnvText(text).values;
344
+ }
345
+ /**
346
+ * Text from bytes, by byte order mark: UTF-16 LE or BE (PowerShell 5.1 `>`
347
+ * writes UTF-16 LE), else UTF-8 without its BOM.
348
+ */
349
+ export function decodeText(buf) {
350
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
351
+ return buf.subarray(2, 2 + ((buf.length - 2) & ~1)).toString("utf16le");
352
+ }
353
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
354
+ return Buffer.from(buf.subarray(2, 2 + ((buf.length - 2) & ~1))).swap16().toString("utf16le");
355
+ }
356
+ return buf.toString("utf8").replace(/^/, "");
357
+ }
358
+ /** `readEnvText` for a file, UTF-8 or UTF-16. A missing file reads as empty. */
359
+ export function readEnvFile(filePath) {
360
+ if (!fs.existsSync(filePath))
361
+ return { values: {}, skipped: [] };
362
+ return readEnvText(decodeText(fs.readFileSync(filePath)));
363
+ }
364
+ /** Names that appear more than once in an env file, with their count. dotenv keeps the last one. */
365
+ export function duplicateEnvNames(filePath) {
366
+ if (!fs.existsSync(filePath))
367
+ return [];
368
+ const counts = new Map();
369
+ const text = decodeText(fs.readFileSync(filePath));
370
+ for (const line of splitEnvEntries(text)) {
371
+ // Same line rule as dotenv: optional `export`, a name, then = or :.
372
+ const m = line.match(/^\s*(?:export\s+)?([\w.-]+)\s*[=:]/);
373
+ if (m?.[1])
374
+ counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
375
+ }
376
+ return [...counts.entries()].filter(([, n]) => n > 1).map(([name, count]) => ({ name, count }));
377
+ }
378
+ /** Lines of an env file, skipping the inside of quoted multiline values. */
379
+ function splitEnvEntries(text) {
380
+ const out = [];
381
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
382
+ let open = null;
383
+ for (const line of lines) {
384
+ if (open) {
385
+ if (line.includes(open))
386
+ open = null;
387
+ continue;
388
+ }
389
+ out.push(line);
390
+ const m = line.match(/^\s*(?:export\s+)?[\w.-]+\s*[=:]\s*(["'`])(.*)$/);
391
+ if (m && !m[2].includes(m[1]))
392
+ open = m[1];
312
393
  }
313
394
  return out;
314
395
  }
315
396
  /** `parseEnvText` for a file. A missing file reads as empty. */
316
397
  export function parseEnvFile(filePath) {
317
- if (!fs.existsSync(filePath))
318
- return {};
319
- return parseEnvText(fs.readFileSync(filePath, "utf8"));
398
+ return readEnvFile(filePath).values;
320
399
  }
321
400
  const ENV_SKIP_DIRS = new Set([
322
401
  "node_modules",
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Staff additions to the known keys list, and the defaults staff turned off,
3
+ * cached in the config dir and refreshed once a day. Any failure keeps the
4
+ * bundled list.
5
+ */
6
+ import { type KnownKeyIndex } from "@getmyenv/shared";
7
+ /** The bundled list plus the cached additions. No network. */
8
+ export declare function knownKeys(): KnownKeyIndex;
9
+ /**
10
+ * Refresh the cache when it is older than a day or from another server.
11
+ * Waits at most 2 seconds. Never throws.
12
+ */
13
+ export declare function refreshKnownKeys(apiUrl: string): Promise<void>;
14
+ /** Tests only. */
15
+ export declare function resetKnownKeysForTests(): void;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Staff additions to the known keys list, and the defaults staff turned off,
3
+ * cached in the config dir and refreshed once a day. Any failure keeps the
4
+ * bundled list.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { BUNDLED_KNOWN_KEYS, createKnownKeyIndex, KNOWN_KEYS } from "@getmyenv/shared";
9
+ import { getConfigDir } from "./fs.js";
10
+ const CACHE_FILE = "known-keys.json";
11
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
12
+ const FETCH_TIMEOUT_MS = 2000;
13
+ const MAX_BODY_CHARS = 2 * 1024 * 1024;
14
+ let index = null;
15
+ function cachePath() {
16
+ return path.join(getConfigDir(), CACHE_FILE);
17
+ }
18
+ function readCache() {
19
+ try {
20
+ const data = JSON.parse(fs.readFileSync(cachePath(), "utf8"));
21
+ if (typeof data.apiUrl !== "string" || typeof data.checkedAt !== "string" || !Array.isArray(data.entries)) {
22
+ return null;
23
+ }
24
+ return {
25
+ apiUrl: data.apiUrl,
26
+ etag: typeof data.etag === "string" ? data.etag : null,
27
+ checkedAt: data.checkedAt,
28
+ entries: data.entries,
29
+ // Caches from older CLIs have no disabled list.
30
+ disabled: Array.isArray(data.disabled) ? data.disabled : [],
31
+ };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function writeCache(data) {
38
+ try {
39
+ fs.writeFileSync(cachePath(), JSON.stringify(data), { mode: 0o600 });
40
+ }
41
+ catch {
42
+ /* keep going without a cache */
43
+ }
44
+ }
45
+ /** The bundled list plus the cached additions. No network. */
46
+ export function knownKeys() {
47
+ if (index)
48
+ return index;
49
+ const cached = readCache();
50
+ index =
51
+ cached && (cached.entries.length || cached.disabled.length)
52
+ ? createKnownKeyIndex(KNOWN_KEYS, cached.entries, cached.disabled)
53
+ : BUNDLED_KNOWN_KEYS;
54
+ return index;
55
+ }
56
+ /**
57
+ * Refresh the cache when it is older than a day or from another server.
58
+ * Waits at most 2 seconds. Never throws.
59
+ */
60
+ export async function refreshKnownKeys(apiUrl) {
61
+ if (!apiUrl)
62
+ return;
63
+ const cached = readCache();
64
+ const same = cached && cached.apiUrl === apiUrl ? cached : null;
65
+ if (same && Date.now() - new Date(same.checkedAt).getTime() < CACHE_TTL_MS)
66
+ return;
67
+ try {
68
+ const res = await fetch(`${apiUrl}/api/known-keys`, {
69
+ headers: same?.etag ? { "If-None-Match": same.etag } : {},
70
+ redirect: "error",
71
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
72
+ });
73
+ const checkedAt = new Date().toISOString();
74
+ if (res.status === 304 && same) {
75
+ writeCache({ ...same, checkedAt });
76
+ return;
77
+ }
78
+ if (!res.ok)
79
+ return;
80
+ const text = await res.text();
81
+ if (text.length > MAX_BODY_CHARS)
82
+ return;
83
+ const data = JSON.parse(text);
84
+ if (!Array.isArray(data.entries))
85
+ return;
86
+ writeCache({
87
+ apiUrl,
88
+ etag: res.headers.get("etag"),
89
+ checkedAt,
90
+ entries: data.entries,
91
+ disabled: Array.isArray(data.disabled) ? data.disabled : [],
92
+ });
93
+ index = null;
94
+ }
95
+ catch {
96
+ /* fail open: bundled or stale cache */
97
+ }
98
+ }
99
+ /** Tests only. */
100
+ export function resetKnownKeysForTests() {
101
+ index = null;
102
+ }
@@ -1,3 +1,5 @@
1
1
  /** "Did you mean OPENAI_API_KEY?" for names close to a known one. Nothing is renamed. */
2
2
  export declare function didYouMean(name: string): string | null;
3
3
  export declare function printDidYouMean(names: string[], indent?: string): void;
4
+ /** A public prefix (NEXT_PUBLIC_, VITE_, ...) on a value that looks secret. Nothing is blocked. */
5
+ export declare function printPublicSecretWarnings(pairs: Record<string, string>, indent?: string): void;
@@ -1,8 +1,9 @@
1
- import pc from "picocolors";
2
- import { nearMissKnownKey } from "@getmyenv/shared";
1
+ import { pcErr as pc } from "./colors.js";
2
+ import { publicSecretWarning } from "@getmyenv/shared";
3
+ import { knownKeys } from "./known-keys-cache.js";
3
4
  /** "Did you mean OPENAI_API_KEY?" for names close to a known one. Nothing is renamed. */
4
5
  export function didYouMean(name) {
5
- const near = nearMissKnownKey(name);
6
+ const near = knownKeys().nearMiss(name);
6
7
  if (!near)
7
8
  return null;
8
9
  const why = near.source === "sdk" ? `${near.provider} SDKs read that name.` : "That is the common name.";
@@ -15,3 +16,11 @@ export function printDidYouMean(names, indent = "") {
15
16
  console.error(pc.yellow(`${indent}${name}: ${hint}`));
16
17
  }
17
18
  }
19
+ /** A public prefix (NEXT_PUBLIC_, VITE_, ...) on a value that looks secret. Nothing is blocked. */
20
+ export function printPublicSecretWarnings(pairs, indent = "") {
21
+ for (const [name, value] of Object.entries(pairs)) {
22
+ const warn = publicSecretWarning(name, value);
23
+ if (warn)
24
+ console.error(pc.yellow(`${indent}${warn}`));
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ import { type SealedKey } from "@getmyenv/crypto";
2
+ import type { ContextInfo } from "./api.js";
3
+ import type { Linked } from "./bootstrap.js";
4
+ import { type ResolveOk, type ResolvePurpose } from "./resolve.js";
5
+ import { type MissingKey } from "./run-env.js";
6
+ export type ContextData = {
7
+ sealedKey: SealedKey | null;
8
+ values: Array<{
9
+ name: string;
10
+ ciphertext: string;
11
+ }>;
12
+ missing: MissingKey[];
13
+ };
14
+ export type DecryptedContext = {
15
+ values: Record<string, string>;
16
+ missing: MissingKey[];
17
+ /** Null when the context has no values yet. */
18
+ key: Uint8Array | null;
19
+ };
20
+ /** Check names, open the context key and decrypt every value. Runs on this machine only. */
21
+ export declare function decryptContext(linked: Linked, ctx: ContextInfo, data: ContextData): Promise<DecryptedContext>;
22
+ /** Resolve one context (approval for Read-only ones, as `run` does) and decrypt it. */
23
+ export declare function readContextValues(linked: Linked, slug: string, purpose: ResolvePurpose): Promise<DecryptedContext & {
24
+ context: ResolveOk["context"];
25
+ via: ResolveOk["via"];
26
+ }>;
@@ -0,0 +1,22 @@
1
+ import { decryptSecret, parsePayload } from "@getmyenv/crypto";
2
+ import { resolveValues } from "./resolve.js";
3
+ import { assertVariableNames } from "./run-env.js";
4
+ import { contextKey } from "./vault.js";
5
+ /** Check names, open the context key and decrypt every value. Runs on this machine only. */
6
+ export async function decryptContext(linked, ctx, data) {
7
+ assertVariableNames([...data.values.map((v) => v.name), ...data.missing.map((m) => m.name)]);
8
+ const values = {};
9
+ if (data.values.length === 0)
10
+ return { values, missing: data.missing, key: null };
11
+ const key = await contextKey(linked, { ...ctx, sealedKey: data.sealedKey });
12
+ for (const v of data.values) {
13
+ values[v.name] = decryptSecret(key, v.name, parsePayload(v.ciphertext));
14
+ }
15
+ return { values, missing: data.missing, key };
16
+ }
17
+ /** Resolve one context (approval for Read-only ones, as `run` does) and decrypt it. */
18
+ export async function readContextValues(linked, slug, purpose) {
19
+ const res = await resolveValues(linked.client, linked.who.project.id, slug, purpose);
20
+ const decrypted = await decryptContext(linked, res.context, res);
21
+ return { ...decrypted, context: res.context, via: res.via };
22
+ }
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { pcErr as pc } from "./colors.js";
2
2
  import { assertRecoveryWrap, createRecoveryWrap, createUserKeys, formatRecoveryCode, recoveryCodesMatch, wipe, } from "@getmyenv/crypto";
3
3
  import { FACTS } from "@getmyenv/shared";
4
4
  import { readLine } from "./fs.js";
@@ -18,8 +18,9 @@ export type ResolveOk = {
18
18
  }>;
19
19
  missing: MissingKey[];
20
20
  };
21
+ export type ResolvePurpose = "run" | "export" | "push";
21
22
  /**
22
23
  * Ciphertext for one context. Read-only contexts need a server token, an
23
24
  * allowed IP, and a trusted machine or an approval in the dashboard.
24
25
  */
25
- export declare function resolveValues(client: ApiClient, projectId: string, slug: string, purpose: "run" | "export"): Promise<ResolveOk>;
26
+ export declare function resolveValues(client: ApiClient, projectId: string, slug: string, purpose: ResolvePurpose): Promise<ResolveOk>;
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { pcErr as pc } from "./colors.js";
2
2
  import { ApiError } from "./api.js";
3
3
  import { sleep } from "./authorize.js";
4
4
  import { signMachineProof } from "./identity.js";
@@ -1,12 +1,28 @@
1
1
  export { explainLines, mergeEnv, runContextLabel, validationError, type MissingKey } from "@getmyenv/shared";
2
2
  /** Names from the server go into an environment or a file. Refuse any that are not variable names. */
3
3
  export declare function assertVariableNames(names: Iterable<string>): void;
4
+ /**
5
+ * One argument for a Windows program: quoted by the MSVCRT rules (backslashes
6
+ * before a quote or the end are doubled, inner quotes become \"), then every
7
+ * cmd.exe metacharacter, quotes included, is escaped with ^ so cmd passes the
8
+ * text through untouched and never expands %VAR%. A .cmd or .bat file reads
9
+ * its arguments through cmd a second time, so those get a second ^.
10
+ */
11
+ export declare function windowsArgument(arg: string, batch?: boolean): string;
4
12
  /**
5
13
  * One cmd.exe command line from argv. Windows needs the shell to find `npm.cmd`
6
14
  * and friends, and Node does not quote args passed alongside `shell: true`.
7
- * cmd expands %VAR% even inside quotes, so each % is closed out of the quotes
8
- * and escaped with ^.
15
+ * Arguments follow cross-spawn's rules (see `windowsArgument`). The command
16
+ * is quoted when it holds a space, so a path like C:\Program Files works.
17
+ */
18
+ export declare function windowsCommandLine(args: string[], opts?: {
19
+ batch?: boolean;
20
+ }): string;
21
+ /**
22
+ * True when cmd.exe would start `command` from a .cmd or .bat file (npm, npx,
23
+ * pnpm and most node_modules/.bin shims). Looks in the current folder, then
24
+ * PATH, with PATHEXT, the way cmd does.
9
25
  */
10
- export declare function windowsCommandLine(args: string[]): string;
26
+ export declare function isWindowsBatch(command: string, env: Record<string, string | undefined>, cwd?: string): boolean;
11
27
  /** Exit code for a child that ended by signal: 128 + signal number, like a shell. */
12
28
  export declare function signalExitCode(signal: NodeJS.Signals, numbers: Record<string, number>): number;