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
@@ -0,0 +1,189 @@
1
+ import { pc } from "../lib/colors.js";
2
+ import { pushFingerprint } from "@getmyenv/crypto";
3
+ import { describePushDestination, PUSH_PROVIDER_TARGETS, PUSH_PROVIDER_TITLES, PUSH_TOKEN_ENV, publicSecretWarning, } from "@getmyenv/shared";
4
+ import { ensureTokenAndLink } from "../lib/bootstrap.js";
5
+ import { resolveContext } from "../lib/context.js";
6
+ import { promptConfirm } from "../lib/fs.js";
7
+ import { readContextValues } from "../lib/read-context.js";
8
+ import { createNetlifyAdapter } from "../providers/netlify.js";
9
+ import { createVercelAdapter } from "../providers/vercel.js";
10
+ const TOKEN_URLS = {
11
+ vercel: "https://vercel.com/account/tokens",
12
+ netlify: "https://app.netlify.com/user/applications#personal-access-tokens",
13
+ };
14
+ function variables(n) {
15
+ return `${n} ${n === 1 ? "variable" : "variables"}`;
16
+ }
17
+ export function createAdapter(provider, token, opts) {
18
+ const target = opts.target ?? "production";
19
+ if (provider === "vercel") {
20
+ return createVercelAdapter({ token, target, project: opts.project, team: opts.team });
21
+ }
22
+ return createNetlifyAdapter({ token, target, site: opts.site });
23
+ }
24
+ /** Names-only plan. Values never leave this function except to the platform. */
25
+ export function buildPlan(adapter, values, remote, prune) {
26
+ const remoteSet = new Set(remote);
27
+ const reserved = [];
28
+ const upserts = [];
29
+ for (const name of Object.keys(values).sort()) {
30
+ if (adapter.isReserved(name)) {
31
+ reserved.push(name);
32
+ continue;
33
+ }
34
+ upserts.push({
35
+ name,
36
+ value: values[name],
37
+ type: adapter.typeFor(name),
38
+ change: remoteSet.has(name) ? "update" : "add",
39
+ });
40
+ }
41
+ const local = new Set(Object.keys(values));
42
+ const toPrune = prune ? remote.filter((n) => !local.has(n) && !adapter.isReserved(n)).sort() : [];
43
+ return { upserts, prune: toPrune, reserved };
44
+ }
45
+ export function formatPlan(upserts, prune) {
46
+ const width = Math.max(0, ...upserts.map((u) => u.name.length), ...prune.map((n) => n.length));
47
+ return [
48
+ ...upserts.map((u) => {
49
+ const mark = u.change === "add" ? pc.green("+") : pc.yellow("~");
50
+ return ` ${mark} ${u.name.padEnd(width)} ${pc.dim(u.type)}`;
51
+ }),
52
+ ...prune.map((n) => ` ${pc.red("-")} ${n.padEnd(width)} ${pc.dim("(prune)")}`),
53
+ ];
54
+ }
55
+ function printSkipped(title, items) {
56
+ if (items.length === 0)
57
+ return;
58
+ console.log(pc.yellow(title));
59
+ for (const s of items)
60
+ console.log(` ${s.name} ${pc.dim(`- ${s.reason}`)}`);
61
+ }
62
+ /**
63
+ * `push vercel|netlify`: decrypt one context on this machine and write it to
64
+ * the platform with the user's own token. The token never reaches getmyenv.
65
+ */
66
+ export async function pushCommand(providerArg, opts) {
67
+ const provider = providerArg.toLowerCase();
68
+ if (provider !== "vercel" && provider !== "netlify") {
69
+ throw new Error(`Unknown provider: ${providerArg}. Use vercel or netlify.`);
70
+ }
71
+ const title = PUSH_PROVIDER_TITLES[provider];
72
+ const targets = PUSH_PROVIDER_TARGETS[provider];
73
+ const target = opts.target ?? "production";
74
+ if (!targets.includes(target)) {
75
+ throw new Error(`--target for ${provider} is one of: ${targets.join(", ")}.`);
76
+ }
77
+ if (provider === "vercel" && opts.site)
78
+ throw new Error("--site is for netlify. Use --project for vercel.");
79
+ if (provider === "netlify" && (opts.project || opts.team)) {
80
+ throw new Error("--project and --team are for vercel. Use --site for netlify.");
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.
87
+ const tokenEnv = PUSH_TOKEN_ENV[provider];
88
+ const token = process.env[tokenEnv]?.trim();
89
+ if (!token) {
90
+ throw new Error(`Set ${tokenEnv} to your ${title} token. Create one at ${TOKEN_URLS[provider]}`);
91
+ }
92
+ const ctx = resolveContext(linked.who, linked.meta, opts.context);
93
+ const adapter = createAdapter(provider, token, { ...opts, target });
94
+ const dest = await adapter.destination();
95
+ const where = describePushDestination(dest);
96
+ const read = await readContextValues(linked, ctx.slug, "push");
97
+ if (Object.keys(read.values).length === 0 || !read.key) {
98
+ throw new Error(`${ctx.slug} has no values to push.`);
99
+ }
100
+ const contextKey = read.key;
101
+ const { skip, warnings } = adapter.check(read.values);
102
+ const skipNames = new Set(skip.map((s) => s.name));
103
+ const values = Object.fromEntries(Object.entries(read.values).filter(([n]) => !skipNames.has(n)));
104
+ const remote = await adapter.remoteNames();
105
+ const plan = buildPlan(adapter, values, remote, Boolean(opts.prune));
106
+ const pushed = Object.fromEntries(plan.upserts.map((u) => [u.name, u.value]));
107
+ if (plan.upserts.length === 0)
108
+ throw new Error(`Nothing to push from ${ctx.slug}.`);
109
+ console.log(`${pc.bold(ctx.slug)} to ${where}`);
110
+ for (const line of formatPlan(plan.upserts, plan.prune))
111
+ console.log(line);
112
+ console.log("");
113
+ if (plan.reserved.length > 0) {
114
+ console.log(pc.dim(`Not pushed, ${dest.title} sets or reserves these names: ${plan.reserved.join(", ")}`));
115
+ }
116
+ printSkipped("Not pushed:", skip);
117
+ if (read.missing.length > 0) {
118
+ console.log(pc.dim(`No value in ${ctx.slug}, not pushed: ${read.missing.map((m) => m.name).join(", ")}`));
119
+ }
120
+ for (const w of warnings)
121
+ console.log(pc.yellow(w));
122
+ for (const u of plan.upserts) {
123
+ const warn = publicSecretWarning(u.name, u.value);
124
+ if (warn)
125
+ console.log(pc.yellow(warn));
126
+ }
127
+ for (const note of adapter.planNotes(plan.upserts))
128
+ console.log(pc.dim(note));
129
+ if (opts.dryRun) {
130
+ const past = await linked.client
131
+ .request("GET", `/api/cli/push?context=${encodeURIComponent(ctx.slug)}`)
132
+ .catch(() => ({ pushes: [] }));
133
+ const last = past.pushes.find((p) => p.provider === dest.provider && p.externalId === dest.externalId && p.target === dest.target);
134
+ if (last) {
135
+ const same = last.fingerprint === pushFingerprint(contextKey, pushed);
136
+ console.log(same
137
+ ? `No changes in getmyenv since the last push to ${where}.`
138
+ : `Changed in getmyenv since the last push to ${where}.`);
139
+ }
140
+ console.log(pc.dim("Dry run. Nothing was written."));
141
+ return;
142
+ }
143
+ const question = `Push ${variables(plan.upserts.length)} to ${where}?`;
144
+ if (plan.prune.length > 0) {
145
+ console.log(pc.red(`Removes from ${where}: ${plan.prune.join(", ")}`));
146
+ }
147
+ if (!opts.yes) {
148
+ if (!process.stdin.isTTY)
149
+ throw new Error(`${question} Re-run with --yes to confirm.`);
150
+ if (!(await promptConfirm(question, { defaultYes: false }))) {
151
+ console.log(pc.dim("Nothing was written."));
152
+ return;
153
+ }
154
+ }
155
+ const result = await adapter.apply(plan.upserts, plan.prune);
156
+ const readable = result.written.filter((w) => w.type === "readable").length;
157
+ const sensitive = result.written.length - readable;
158
+ if (result.written.length > 0) {
159
+ console.log(`${pc.green("✓")} Pushed ${variables(result.written.length)} to ${where}. Readable: ${readable}. Sensitive: ${sensitive}.`);
160
+ console.log(`That copy lives in ${dest.title}.`);
161
+ console.log("Future changes in getmyenv are not pushed automatically.");
162
+ }
163
+ if (result.pruned.length > 0)
164
+ console.log(`Removed: ${result.pruned.join(", ")}`);
165
+ for (const note of result.notes)
166
+ console.log(pc.yellow(note));
167
+ printSkipped("Not pruned:", result.notPruned);
168
+ printSkipped("Not pushed:", result.notWritten);
169
+ if (result.written.length > 0) {
170
+ const written = Object.fromEntries(result.written.map((w) => [w.name, pushed[w.name]]));
171
+ try {
172
+ await linked.client.request("POST", "/api/cli/push", {
173
+ context: ctx.slug,
174
+ provider: dest.provider,
175
+ target: dest.target,
176
+ externalId: dest.externalId,
177
+ externalName: dest.externalName,
178
+ ownerSlug: dest.ownerSlug,
179
+ names: Object.keys(written).sort(),
180
+ fingerprint: pushFingerprint(contextKey, written),
181
+ });
182
+ }
183
+ catch (err) {
184
+ console.log(pc.yellow(`The push worked, but getmyenv could not record it: ${err.message}`));
185
+ }
186
+ }
187
+ if (result.notWritten.length > 0)
188
+ process.exitCode = 1;
189
+ }
@@ -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,48 +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";
4
- import { decryptSecret, parsePayload } from "@getmyenv/crypto";
3
+ import { pcErr as pc } from "../lib/colors.js";
5
4
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
6
5
  import { resolveContext } from "../lib/context.js";
7
6
  import { pendingFirstRunHints } from "../lib/first-run.js";
8
- import { resolveValues } from "../lib/resolve.js";
9
- import { contextKey } from "../lib/vault.js";
10
- import { assertVariableNames, explainLines, mergeEnv, runContextLabel, signalExitCode, validationError, windowsCommandLine, } from "../lib/run-env.js";
7
+ import { readContextValues } from "../lib/read-context.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";
11
11
  /** Status lines go to stderr so the child's stdout stays clean for pipes. */
12
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
+ }
13
47
  /** Resolve, validate, merge, spawn. */
14
48
  export async function runCommand(opts, childArgs) {
15
49
  if (childArgs.length === 0 && !opts.explain) {
16
50
  throw new Error("Usage: npx getmyenv run [context] -- <command>");
17
51
  }
18
- const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
19
- const { who, client, meta } = linked;
20
- const ctx = resolveContext(who, meta, opts.context);
21
- const res = await resolveValues(client, who.project.id, ctx.slug, "run");
22
- assertVariableNames([...res.values.map((v) => v.name), ...res.missing.map((m) => m.name)]);
23
- const values = {};
24
- if (res.values.length > 0) {
25
- const key = await contextKey(linked, { ...res.context, sealedKey: res.sealedKey });
26
- for (const v of res.values) {
27
- values[v.name] = decryptSecret(key, v.name, parsePayload(v.ciphertext));
28
- }
29
- }
30
- const merged = mergeEnv(process.env, values, res.missing, opts.keepExisting === true);
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;
58
+ const merged = mergeEnv(process.env, res.values, res.missing, opts.keepExisting === true);
31
59
  const label = runContextLabel(res.context);
32
60
  if (opts.explain) {
33
61
  log(pc.bold(`${who.project.name} / ${label}`));
34
- for (const line of explainLines(merged, res.context.slug))
62
+ for (const line of explainLines(merged, res.context.slug, { allowUnsafeEnv: opts.allowUnsafeEnv }))
35
63
  log(line);
36
64
  log();
37
65
  }
38
66
  const invalid = validationError(merged, res.context.slug, opts.allowMissing === true);
39
67
  if (invalid)
40
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);
41
72
  if (childArgs.length === 0)
42
73
  return;
43
74
  if (!opts.explain) {
44
75
  log(`${pc.green("✓")} ${who.project.name} / ${label}: ${merged.applied.length} variable${merged.applied.length === 1 ? "" : "s"}`);
45
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
+ }
46
80
  if (merged.overridden.length > 0) {
47
81
  log(pc.yellow(`Replaced from parent env: ${merged.overridden.join(", ")}`));
48
82
  }
@@ -67,15 +101,20 @@ export async function runCommand(opts, childArgs) {
67
101
  log(pc.dim(line));
68
102
  const [cmd, ...args] = childArgs;
69
103
  const child = process.platform === "win32"
70
- ? 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
+ })
71
109
  : spawn(cmd, args, { env: merged.env, stdio: "inherit" });
72
110
  // Ctrl+C already reaches the child through the terminal. Only wait for it.
73
111
  process.on("SIGINT", () => undefined);
74
- // SIGTERM / SIGHUP are sent to this process only. Pass them on.
75
- 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) {
76
115
  process.on(sig, () => {
77
116
  if (child.exitCode === null && child.signalCode === null)
78
- child.kill(sig);
117
+ stopChild(child.pid, sig, () => child.kill(sig));
79
118
  });
80
119
  }
81
120
  child.once("spawn", hints.markShown);
@@ -4,9 +4,25 @@ export type SetOpts = {
4
4
  expires?: string;
5
5
  /** Read the one value from stdin, so it never lands in argv or shell history. */
6
6
  stdin?: boolean;
7
+ /** Prompt for every name with no value in the context. */
8
+ missing?: boolean;
7
9
  };
10
+ /** Names declared in the project with no value in this context, in project order. */
11
+ export declare function missingNames(keys: Array<{
12
+ name: string;
13
+ status: Record<string, string>;
14
+ }>, slug: string): string[];
15
+ export declare function parseExpires(raw: string | undefined, now?: Date): string | null | undefined;
8
16
  /** `set KEY[=VALUE] ...` Omitted values are prompted without echo. */
9
17
  export declare function setCommand(args: string[], opts: SetOpts): Promise<void>;
18
+ /**
19
+ * Why unset changed nothing. A declared name with no value in the context
20
+ * stays in the project, and run still asks for it, so point to --all.
21
+ */
22
+ export declare function unsetNoChangeLines(skipped: string[], keys: Array<{
23
+ name: string;
24
+ status: Record<string, string>;
25
+ }>, slug: string, all: boolean): string[];
10
26
  export declare function unsetCommand(names: string[], opts: {
11
27
  context?: string;
12
28
  apiUrl?: string;
@@ -1,9 +1,10 @@
1
- import pc from "picocolors";
2
- import { variableNameSchema } from "@getmyenv/shared";
1
+ import { pc, pcErr } from "../lib/colors.js";
2
+ import { ownEnvNameError, plural, suggestVariableName, unsafeEnvWarning, VARIABLE_NAME_HELP, variableNameSchema } from "@getmyenv/shared";
3
3
  import { promptHidden } from "../lib/fs.js";
4
4
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
5
5
  import { resolveContext } from "../lib/context.js";
6
- import { printDidYouMean } from "../lib/known-keys.js";
6
+ import { printDidYouMean, printPublicSecretWarnings } from "../lib/known-keys.js";
7
+ import { refreshKnownKeys } from "../lib/known-keys-cache.js";
7
8
  import { syncFromLinked } from "../lib/template-file.js";
8
9
  import { assertWritable, uploadValues } from "../lib/upload.js";
9
10
  async function readStdinValue() {
@@ -13,31 +14,57 @@ async function readStdinValue() {
13
14
  }
14
15
  return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
15
16
  }
17
+ /** Names declared in the project with no value in this context, in project order. */
18
+ export function missingNames(keys, slug) {
19
+ return keys.filter((k) => (k.status[slug] ?? "missing") === "missing").map((k) => k.name);
20
+ }
21
+ function invalidNameMessage(name) {
22
+ const hint = suggestVariableName(name);
23
+ return `Invalid name: ${name}. ${VARIABLE_NAME_HELP}${hint ? ` Did you mean ${hint}?` : ""}`;
24
+ }
16
25
  function parsePair(arg) {
17
26
  const eq = arg.indexOf("=");
18
27
  if (eq <= 0)
19
28
  return { name: arg.trim() };
20
29
  return { name: arg.slice(0, eq).trim(), value: arg.slice(eq + 1) };
21
30
  }
22
- function parseExpires(raw) {
31
+ /** A date a year from now, as YYYY-MM-DD, for examples that do not age. */
32
+ function exampleDate(now) {
33
+ const d = new Date(now);
34
+ d.setUTCFullYear(d.getUTCFullYear() + 1);
35
+ return d.toISOString().slice(0, 10);
36
+ }
37
+ export function parseExpires(raw, now = new Date()) {
23
38
  if (raw === undefined)
24
39
  return undefined;
25
40
  if (raw === "never" || raw === "")
26
41
  return null;
27
42
  const d = new Date(raw);
28
43
  if (Number.isNaN(d.getTime())) {
29
- throw new Error("--expires takes a date like 2026-12-31, or never.");
44
+ throw new Error(`--expires takes a date like ${exampleDate(now)}, or never.`);
45
+ }
46
+ if (d.getTime() <= now.getTime()) {
47
+ throw new Error(`--expires must be a future date, like ${exampleDate(now)}, or never.`);
30
48
  }
31
49
  return d.toISOString();
32
50
  }
33
51
  /** `set KEY[=VALUE] ...` Omitted values are prompted without echo. */
34
52
  export async function setCommand(args, opts) {
53
+ if (opts.missing && (args.length > 0 || opts.stdin)) {
54
+ throw new Error("--missing asks for every variable with no value. Run it without names or --stdin: npx getmyenv set --missing");
55
+ }
56
+ if (!opts.missing && args.length === 0) {
57
+ throw new Error("Name a variable: npx getmyenv set KEY, or ask for every missing one: npx getmyenv set --missing");
58
+ }
35
59
  const pairs = args.map(parsePair);
36
60
  for (const p of pairs) {
37
61
  if (!variableNameSchema.safeParse(p.name).success) {
38
- throw new Error(`Invalid name: ${p.name}. Use an environment variable name like API_KEY.`);
62
+ throw new Error(invalidNameMessage(p.name));
39
63
  }
40
64
  }
65
+ const own = ownEnvNameError(pairs.map((p) => p.name));
66
+ if (own)
67
+ throw new Error(own);
41
68
  const expiresAt = parseExpires(opts.expires);
42
69
  if (opts.stdin) {
43
70
  if (pairs.length !== 1 || pairs[0].value !== undefined) {
@@ -50,9 +77,26 @@ export async function setCommand(args, opts) {
50
77
  const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
51
78
  const ctx = resolveContext(linked.who, linked.meta, opts.context);
52
79
  assertWritable(linked, ctx);
80
+ if (opts.missing) {
81
+ const names = missingNames(linked.who.keys, ctx.slug);
82
+ if (names.length === 0) {
83
+ console.log(`${pc.green("✓")} Every variable in ${ctx.slug} has a value.`);
84
+ return;
85
+ }
86
+ if (!process.stdin.isTTY) {
87
+ throw new Error(`${plural(names.length, "variable")} ${names.length === 1 ? "has" : "have"} no value in ${ctx.slug}: ${names.join(", ")}. No terminal to ask in. Ask the human to run npx getmyenv set --missing in a terminal.`);
88
+ }
89
+ console.error(pcErr.dim(`${plural(names.length, "variable")} ${names.length === 1 ? "has" : "have"} no value in ${ctx.slug}. Leave one empty to skip it.`));
90
+ for (const name of names)
91
+ pairs.push({ name });
92
+ }
53
93
  const values = {};
54
- if (opts.stdin)
55
- values[pairs[0].name] = await readStdinValue();
94
+ if (opts.stdin) {
95
+ const piped = await readStdinValue();
96
+ if (piped === "")
97
+ throw new Error(`stdin was empty. Pipe a value, or declare the name with ${pairs[0].name}=`);
98
+ values[pairs[0].name] = piped;
99
+ }
56
100
  for (const p of pairs) {
57
101
  if (opts.stdin)
58
102
  break;
@@ -61,9 +105,17 @@ export async function setCommand(args, opts) {
61
105
  continue;
62
106
  }
63
107
  if (!process.stdin.isTTY) {
64
- throw new Error(`No value for ${p.name}. Pipe it with --stdin, pass ${p.name}=VALUE, or run in a terminal to be prompted.`);
108
+ throw new Error(`No value for ${p.name}, and no terminal to ask in. Ask the human to run npx getmyenv set ${p.name} in a terminal, or pass ${p.name}=VALUE, or pipe it with --stdin.`);
65
109
  }
66
- values[p.name] = await promptHidden(`${p.name} (hidden): `);
110
+ const typed = await promptHidden(`${p.name} (hidden): `);
111
+ // With --missing, an empty answer skips the name. It already exists with no value.
112
+ if (opts.missing && typed === "")
113
+ continue;
114
+ values[p.name] = typed;
115
+ }
116
+ if (Object.keys(values).length === 0) {
117
+ console.log(pc.dim("Nothing to set."));
118
+ return;
67
119
  }
68
120
  const res = await uploadValues({
69
121
  linked,
@@ -75,13 +127,45 @@ export async function setCommand(args, opts) {
75
127
  console.log(`${pc.green("✓")} ${name} ${pc.dim(`set in ${ctx.slug}`)}`);
76
128
  for (const name of res.declared)
77
129
  console.log(`${pc.yellow("○")} ${name} ${pc.dim(`declared, no value in ${ctx.slug}`)}`);
78
- printDidYouMean(pairs.map((p) => p.name).filter((n) => !linked.who.keys.some((k) => k.name === n)));
130
+ // KEY= on a name that already exists: the server keeps it as it is. Say so.
131
+ for (const [name, value] of Object.entries(values)) {
132
+ if (value !== "" || res.declared.includes(name))
133
+ continue;
134
+ const status = linked.who.keys.find((k) => k.name === name)?.status[ctx.slug];
135
+ console.log(status && status !== "missing"
136
+ ? `${pc.dim("=")} ${name} ${pc.dim(`already has a value in ${ctx.slug}. An empty value does not clear it: npx getmyenv unset ${name}`)}`
137
+ : `${pc.dim("=")} ${name} ${pc.dim(`already declared, no value in ${ctx.slug}`)}`);
138
+ }
139
+ const newNames = pairs.map((p) => p.name).filter((n) => !linked.who.keys.some((k) => k.name === n));
140
+ if (newNames.length > 0)
141
+ await refreshKnownKeys(linked.apiUrl);
142
+ printDidYouMean(newNames);
143
+ printPublicSecretWarnings(values);
144
+ const unsafe = unsafeEnvWarning(Object.keys(values));
145
+ if (unsafe)
146
+ console.error(pcErr.yellow(unsafe));
79
147
  syncFromLinked(linked, { added: [...res.set, ...res.declared] });
80
148
  }
149
+ /**
150
+ * Why unset changed nothing. A declared name with no value in the context
151
+ * stays in the project, and run still asks for it, so point to --all.
152
+ */
153
+ export function unsetNoChangeLines(skipped, keys, slug, all) {
154
+ const declared = all ? [] : skipped.filter((n) => keys.some((k) => k.name === n));
155
+ const other = skipped.filter((n) => !declared.includes(n));
156
+ const lines = [];
157
+ if (declared.length > 0) {
158
+ const one = declared.length === 1;
159
+ lines.push(`${declared.join(", ")} ${one ? "has" : "have"} no value in ${slug}. The ${one ? "name stays" : "names stay"} in the project, and run asks for ${one ? "it" : "them"}. Remove ${one ? "it" : "them"}: npx getmyenv unset ${declared.join(" ")} --all`);
160
+ }
161
+ if (other.length > 0)
162
+ lines.push(`No change: ${other.join(", ")}`);
163
+ return lines;
164
+ }
81
165
  export async function unsetCommand(names, opts) {
82
166
  for (const n of names) {
83
167
  if (!variableNameSchema.safeParse(n).success)
84
- throw new Error(`Invalid name: ${n}`);
168
+ throw new Error(invalidNameMessage(n));
85
169
  }
86
170
  const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
87
171
  const ctx = resolveContext(linked.who, linked.meta, opts.context);
@@ -95,7 +179,7 @@ export async function unsetCommand(names, opts) {
95
179
  for (const name of res.removed)
96
180
  console.log(`${pc.green("✓")} ${name} ${pc.dim(where)}`);
97
181
  const skipped = names.filter((n) => !res.removed.includes(n));
98
- if (skipped.length > 0)
99
- console.log(pc.dim(`No change: ${skipped.join(", ")}`));
182
+ for (const line of unsetNoChangeLines(skipped, linked.who.keys, ctx.slug, opts.all === true))
183
+ console.log(pc.dim(line));
100
184
  syncFromLinked(linked, opts.all ? { removed: res.removed } : {});
101
185
  }
@@ -1,11 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import pc from "picocolors";
4
- import { DEFAULT_CONTEXT_SLUG, FACTS, GUEST_TOTAL_DAYS, TEMPLATE_FILE, } from "@getmyenv/shared";
3
+ import { pcErr as pc } from "../lib/colors.js";
4
+ import { DEFAULT_CONTEXT_SLUG, FACTS, TEMPLATE_FILE, } from "@getmyenv/shared";
5
5
  import { promptChoice, promptConfirm, readClaimFile, readLine, readProjectMeta, rootEnvFiles, writeProjectMeta, } from "../lib/fs.js";
6
6
  import { ApiClient, getSessionToken, resolveApiUrl } from "../lib/api.js";
7
7
  import { authorizeFolder } from "../lib/authorize.js";
8
- import { NOT_LINKED_STEPS } from "../lib/bootstrap.js";
8
+ import { notLinkedMessage } from "../lib/bootstrap.js";
9
9
  import { readTemplateFile } from "../lib/template-file.js";
10
10
  import { createGuestProject, folderNameHint } from "./guest.js";
11
11
  import { statusCommand } from "./status.js";
@@ -95,7 +95,7 @@ export async function startSetup(opts) {
95
95
  }
96
96
  return;
97
97
  }
98
- const choice = await promptChoice("How do you want to start?", [`Start without an account (guest project, claim within ${GUEST_TOTAL_DAYS} days)`, "Sign in"], 0);
98
+ const choice = await promptChoice("How do you want to start?", [`Start without an account (guest project, active while you use it)`, "Sign in"], 0);
99
99
  console.error("");
100
100
  let context = DEFAULT_CONTEXT_SLUG;
101
101
  let dashboardUrl = null;
@@ -171,7 +171,7 @@ export function startChoices(existing, file) {
171
171
  async function startFromTemplateFile(file, opts) {
172
172
  const n = file.keys.length;
173
173
  console.error(`${TEMPLATE_FILE} lists ${n} variable${n === 1 ? "" : "s"}. Values are asked after setup.\n`);
174
- const choice = await promptChoice("How do you want to start?", [`Start without an account (guest project, claim within ${GUEST_TOTAL_DAYS} days)`, "Sign in"], 0);
174
+ const choice = await promptChoice("How do you want to start?", [`Start without an account (guest project, active while you use it)`, "Sign in"], 0);
175
175
  console.error("");
176
176
  const suggested = file.name ?? folderNameHint();
177
177
  let project;
@@ -230,8 +230,8 @@ export async function startCommand(opts) {
230
230
  return;
231
231
  }
232
232
  if (!process.stdin.isTTY) {
233
- console.log(NOT_LINKED_STEPS);
234
- return;
233
+ // No terminal to set up in: an agent or a script must not read this as success.
234
+ throw new Error(notLinkedMessage());
235
235
  }
236
236
  await startSetup({ apiUrl: opts.apiUrl, template });
237
237
  }
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { pc } from "../lib/colors.js";
2
2
  import { NEW_MACHINE_POLICY_LABELS } from "@getmyenv/shared";
3
3
  import { isDefaultApiUrl } from "../lib/api.js";
4
4
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
@@ -8,7 +8,7 @@ import { runLine } from "./start.js";
8
8
  const TOKEN_KIND = {
9
9
  folder: "folder (open contexts, read and write)",
10
10
  guest: "guest (Development, read and write)",
11
- server: "server (one read-only context)",
11
+ server: "server (one Read-only context)",
12
12
  };
13
13
  /** Project, token, contexts with value counts, and the run command. Names only. */
14
14
  export async function statusCommand(opts = {}) {
@@ -36,7 +36,7 @@ export async function statusCommand(opts = {}) {
36
36
  return s === "set" || s === "expiring_soon";
37
37
  }).length;
38
38
  const marker = c.slug === current ? pc.green("*") : " ";
39
- const lock = c.protection === "locked" ? pc.yellow(" read-only") : "";
39
+ const lock = c.protection === "locked" ? pc.yellow(" Read-only") : "";
40
40
  const name = c.name.toLowerCase() === c.slug ? "" : pc.dim(` ${c.name}`);
41
41
  console.log(` ${marker} ${c.slug.padEnd(width)} ${set}/${total} set${lock}${name}`);
42
42
  }