getmyenv 0.13.0 → 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 (54) hide show
  1. package/README.md +14 -12
  2. package/dist/bin.js +8 -0
  3. package/dist/cli.js +11 -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 +17 -11
  8. package/dist/commands/guest.d.ts +1 -1
  9. package/dist/commands/guest.js +17 -6
  10. package/dist/commands/import.js +38 -9
  11. package/dist/commands/lock.js +1 -1
  12. package/dist/commands/ls.js +2 -2
  13. package/dist/commands/push.js +6 -5
  14. package/dist/commands/restore.js +3 -3
  15. package/dist/commands/run.d.ts +1 -0
  16. package/dist/commands/run.js +61 -12
  17. package/dist/commands/set.d.ts +16 -0
  18. package/dist/commands/set.js +91 -12
  19. package/dist/commands/start.js +7 -7
  20. package/dist/commands/status.js +3 -3
  21. package/dist/commands/template.js +3 -3
  22. package/dist/commands/unlink.d.ts +14 -5
  23. package/dist/commands/unlink.js +79 -17
  24. package/dist/lib/api-url.d.ts +2 -0
  25. package/dist/lib/api-url.js +10 -0
  26. package/dist/lib/api.d.ts +22 -2
  27. package/dist/lib/api.js +108 -34
  28. package/dist/lib/authorize.js +2 -2
  29. package/dist/lib/bootstrap.d.ts +5 -0
  30. package/dist/lib/bootstrap.js +32 -8
  31. package/dist/lib/colors.d.ts +14 -0
  32. package/dist/lib/colors.js +17 -0
  33. package/dist/lib/confirm.d.ts +7 -3
  34. package/dist/lib/confirm.js +21 -12
  35. package/dist/lib/context.js +1 -1
  36. package/dist/lib/credentials.d.ts +4 -0
  37. package/dist/lib/credentials.js +13 -11
  38. package/dist/lib/fs.d.ts +36 -1
  39. package/dist/lib/fs.js +102 -23
  40. package/dist/lib/known-keys-cache.d.ts +3 -2
  41. package/dist/lib/known-keys-cache.js +16 -4
  42. package/dist/lib/known-keys.js +1 -1
  43. package/dist/lib/recovery-code.js +1 -1
  44. package/dist/lib/resolve.js +1 -1
  45. package/dist/lib/run-env.d.ts +19 -3
  46. package/dist/lib/run-env.js +66 -13
  47. package/dist/lib/template-file.js +1 -1
  48. package/dist/lib/update-check.js +1 -1
  49. package/dist/lib/upload.js +2 -2
  50. package/dist/lib/vault.js +1 -1
  51. package/dist/providers/http.js +15 -12
  52. package/dist/providers/netlify.js +3 -1
  53. package/dist/providers/vercel.js +3 -1
  54. package/package.json +19 -12
@@ -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,5 +1,5 @@
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";
@@ -14,31 +14,57 @@ async function readStdinValue() {
14
14
  }
15
15
  return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
16
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
+ }
17
25
  function parsePair(arg) {
18
26
  const eq = arg.indexOf("=");
19
27
  if (eq <= 0)
20
28
  return { name: arg.trim() };
21
29
  return { name: arg.slice(0, eq).trim(), value: arg.slice(eq + 1) };
22
30
  }
23
- 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()) {
24
38
  if (raw === undefined)
25
39
  return undefined;
26
40
  if (raw === "never" || raw === "")
27
41
  return null;
28
42
  const d = new Date(raw);
29
43
  if (Number.isNaN(d.getTime())) {
30
- 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.`);
31
48
  }
32
49
  return d.toISOString();
33
50
  }
34
51
  /** `set KEY[=VALUE] ...` Omitted values are prompted without echo. */
35
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
+ }
36
59
  const pairs = args.map(parsePair);
37
60
  for (const p of pairs) {
38
61
  if (!variableNameSchema.safeParse(p.name).success) {
39
- throw new Error(`Invalid name: ${p.name}. Use an environment variable name like API_KEY.`);
62
+ throw new Error(invalidNameMessage(p.name));
40
63
  }
41
64
  }
65
+ const own = ownEnvNameError(pairs.map((p) => p.name));
66
+ if (own)
67
+ throw new Error(own);
42
68
  const expiresAt = parseExpires(opts.expires);
43
69
  if (opts.stdin) {
44
70
  if (pairs.length !== 1 || pairs[0].value !== undefined) {
@@ -51,9 +77,26 @@ export async function setCommand(args, opts) {
51
77
  const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
52
78
  const ctx = resolveContext(linked.who, linked.meta, opts.context);
53
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
+ }
54
93
  const values = {};
55
- if (opts.stdin)
56
- 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
+ }
57
100
  for (const p of pairs) {
58
101
  if (opts.stdin)
59
102
  break;
@@ -62,9 +105,17 @@ export async function setCommand(args, opts) {
62
105
  continue;
63
106
  }
64
107
  if (!process.stdin.isTTY) {
65
- 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.`);
66
109
  }
67
- 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;
68
119
  }
69
120
  const res = await uploadValues({
70
121
  linked,
@@ -76,17 +127,45 @@ export async function setCommand(args, opts) {
76
127
  console.log(`${pc.green("✓")} ${name} ${pc.dim(`set in ${ctx.slug}`)}`);
77
128
  for (const name of res.declared)
78
129
  console.log(`${pc.yellow("○")} ${name} ${pc.dim(`declared, no value in ${ctx.slug}`)}`);
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
+ }
79
139
  const newNames = pairs.map((p) => p.name).filter((n) => !linked.who.keys.some((k) => k.name === n));
80
140
  if (newNames.length > 0)
81
141
  await refreshKnownKeys(linked.apiUrl);
82
142
  printDidYouMean(newNames);
83
143
  printPublicSecretWarnings(values);
144
+ const unsafe = unsafeEnvWarning(Object.keys(values));
145
+ if (unsafe)
146
+ console.error(pcErr.yellow(unsafe));
84
147
  syncFromLinked(linked, { added: [...res.set, ...res.declared] });
85
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
+ }
86
165
  export async function unsetCommand(names, opts) {
87
166
  for (const n of names) {
88
167
  if (!variableNameSchema.safeParse(n).success)
89
- throw new Error(`Invalid name: ${n}`);
168
+ throw new Error(invalidNameMessage(n));
90
169
  }
91
170
  const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
92
171
  const ctx = resolveContext(linked.who, linked.meta, opts.context);
@@ -100,7 +179,7 @@ export async function unsetCommand(names, opts) {
100
179
  for (const name of res.removed)
101
180
  console.log(`${pc.green("✓")} ${name} ${pc.dim(where)}`);
102
181
  const skipped = names.filter((n) => !res.removed.includes(n));
103
- if (skipped.length > 0)
104
- 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));
105
184
  syncFromLinked(linked, opts.all ? { removed: res.removed } : {});
106
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
  }
@@ -1,4 +1,4 @@
1
- import pc from "picocolors";
1
+ import { pcErr as pc } from "../lib/colors.js";
2
2
  import { linkDomain, parseTemplateRef, } from "@getmyenv/shared";
3
3
  import { promptHidden } from "../lib/fs.js";
4
4
  import { ApiClient, getSessionToken, resolveApiUrl } from "../lib/api.js";
@@ -62,7 +62,7 @@ export async function applyTemplateFile(file, opts) {
62
62
  const todo = missingIn(file.keys, linked, ctx);
63
63
  if (todo.length > 0) {
64
64
  console.error(`No value yet in ${ctx.slug}: ${todo.map((k) => k.name).join(", ")}`);
65
- console.error("Set each one: npx getmyenv set NAME\n");
65
+ console.error(`Set ${todo.length === 1 ? "it" : "them"} in a terminal: npx getmyenv set --missing\n`);
66
66
  }
67
67
  return { context: ctx.slug };
68
68
  }
@@ -109,7 +109,7 @@ export async function askMissingValues(keys, linked, ctx) {
109
109
  }
110
110
  const skipped = todo.filter((k) => !pairs[k.name]).map((k) => k.name);
111
111
  if (skipped.length > 0) {
112
- console.error(pc.dim(`No value yet: ${skipped.join(", ")}. Set later with npx getmyenv set NAME`));
112
+ console.error(pc.dim(`No value yet: ${skipped.join(", ")}. Set later in a terminal: npx getmyenv set --missing`));
113
113
  }
114
114
  console.error("");
115
115
  }
@@ -1,8 +1,17 @@
1
+ export type UnlinkOpts = {
2
+ apiUrl?: string;
3
+ yes?: boolean;
4
+ local?: boolean;
5
+ };
6
+ /** Why the revoke failed, and the ways on. Nothing was removed. */
7
+ export declare function revokeFailedMessage(opts: {
8
+ unreachable: boolean;
9
+ guest: boolean;
10
+ }): string;
1
11
  /**
2
12
  * Unlink this folder: revoke its token and remove local getmyenv files.
3
- * The project, its contexts and values stay.
13
+ * The project, its contexts and values stay. A committed project.json stays
14
+ * when this machine has no token: it belongs to the repo, not to this machine.
15
+ * --local removes the files without revoking the token.
4
16
  */
5
- export declare function unlinkCommand(opts: {
6
- apiUrl?: string;
7
- yes?: boolean;
8
- }): Promise<void>;
17
+ export declare function unlinkCommand(opts: UnlinkOpts): Promise<void>;
@@ -1,37 +1,85 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import pc from "picocolors";
4
- import { getLocalDir, promptConfirm, readClaimFile, readProjectMeta } from "../lib/fs.js";
5
- import { clearFolderToken, readFolderToken } from "../lib/credentials.js";
3
+ import { pc } from "../lib/colors.js";
4
+ import { TOKEN_ENDED_CODE } from "@getmyenv/shared";
5
+ import { clearClaimFile, getLocalDir, promptConfirm, readClaimFile, readProjectMeta, } from "../lib/fs.js";
6
+ import { clearFolderToken, isTrackedByGit, readFolderToken, readFolderTokenLenient, tokenFilePath, } from "../lib/credentials.js";
6
7
  import { clearCachedKeys, keychainAccount } from "../lib/keychain.js";
7
8
  import { ApiClient, ApiError, resolveApiUrl } from "../lib/api.js";
9
+ /** project.json as far as it can be read. A broken one is still removed with the token. */
10
+ function readMetaLenient() {
11
+ try {
12
+ return readProjectMeta();
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /** token.json that exists but cannot be read as a token. Its token cannot be revoked from here. */
19
+ function tokenFileIsBroken() {
20
+ if (!fs.existsSync(tokenFilePath()))
21
+ return false;
22
+ try {
23
+ return readFolderToken() === null;
24
+ }
25
+ catch {
26
+ return true;
27
+ }
28
+ }
29
+ /** Why the revoke failed, and the ways on. Nothing was removed. */
30
+ export function revokeFailedMessage(opts) {
31
+ const first = opts.unreachable
32
+ ? "Could not reach the server to revoke this folder's token. Nothing was removed."
33
+ : "Could not revoke the token on the server. Nothing was removed.";
34
+ const retry = opts.guest
35
+ ? "Try again when the server is reachable."
36
+ : "Try again, or revoke it in Project settings, Tokens first.";
37
+ return `${first} ${retry} To remove only the local files: npx getmyenv unlink --local`;
38
+ }
8
39
  /**
9
40
  * Unlink this folder: revoke its token and remove local getmyenv files.
10
- * The project, its contexts and values stay.
41
+ * The project, its contexts and values stay. A committed project.json stays
42
+ * when this machine has no token: it belongs to the repo, not to this machine.
43
+ * --local removes the files without revoking the token.
11
44
  */
12
45
  export async function unlinkCommand(opts) {
13
- const meta = readProjectMeta();
14
- const folder = readFolderToken();
46
+ const meta = readMetaLenient();
47
+ const folder = readFolderTokenLenient();
48
+ const brokenToken = tokenFileIsBroken();
15
49
  const projectFile = path.join(getLocalDir(), "project.json");
16
- if (!folder && !meta?.projectId && !fs.existsSync(projectFile)) {
50
+ const hasToken = Boolean(folder) || brokenToken;
51
+ const keepProjectFile = !hasToken && fs.existsSync(projectFile) && isTrackedByGit(projectFile);
52
+ if (!hasToken && !meta?.projectId && !fs.existsSync(projectFile)) {
17
53
  console.log(pc.dim("This folder is not linked."));
18
54
  return;
19
55
  }
20
56
  if (!opts.yes && !process.stdin.isTTY) {
21
- throw new Error("Unlink revokes this folder's token. Without a terminal, pass --yes.");
57
+ throw new Error(folder && !opts.local
58
+ ? "Unlink revokes this folder's token. Without a terminal, pass --yes."
59
+ : "Unlink removes this folder's .getmyenv files. Without a terminal, pass --yes.");
22
60
  }
23
61
  if (!opts.yes) {
24
62
  const label = meta?.projectName ?? meta?.projectId ?? "this folder";
25
- const tokenLine = readClaimFile()
26
- ? "Removes .getmyenv files except claim.json. The guest token stays, so claim still works."
27
- : "Revokes this folder's token and removes .getmyenv files.";
28
- const ok = await promptConfirm(`Unlink ${label}? ${tokenLine} The project and its values stay.`, { defaultYes: false });
63
+ const tokenLine = !hasToken
64
+ ? "This machine has no token for it. Removes the local .getmyenv files."
65
+ : readClaimFile()
66
+ ? "Removes .getmyenv files except claim.json. The guest token stays, so claim still works."
67
+ : opts.local
68
+ ? "Removes .getmyenv files without revoking the token."
69
+ : "Revokes this folder's token and removes .getmyenv files.";
70
+ const ok = await promptConfirm(`Unlink ${label}? ${tokenLine} The project and its values stay.`, {
71
+ defaultYes: false,
72
+ });
29
73
  if (!ok) {
30
74
  console.log(pc.dim("Kept."));
31
75
  return;
32
76
  }
33
77
  }
34
- if (folder) {
78
+ let guestEnded = false;
79
+ if (folder && opts.local) {
80
+ console.log(pc.yellow("The token was not revoked. It keeps working on the server until it expires or its project ends. Revoke it in Project settings, Tokens."));
81
+ }
82
+ else if (folder) {
35
83
  try {
36
84
  const res = await new ApiClient(resolveApiUrl(opts.apiUrl), folder.token).request("DELETE", "/api/cli/token");
37
85
  if (res?.revoked === false) {
@@ -45,22 +93,36 @@ export async function unlinkCommand(opts) {
45
93
  // 401/404: the token is already gone on the server. Anything else keeps
46
94
  // token.json, so a still-valid token is never left without a local copy.
47
95
  if (!(err instanceof ApiError && (err.status === 401 || err.status === 404))) {
48
- throw new Error("Could not revoke the token on the server. Nothing was removed. Try again, or revoke it in Project settings, Tokens first.");
96
+ throw new Error(revokeFailedMessage({
97
+ unreachable: err instanceof ApiError && err.status === 0,
98
+ guest: readClaimFile() !== null,
99
+ }));
49
100
  }
50
- console.log(pc.dim("Token was already revoked."));
101
+ console.log(pc.dim("The token already no longer works on the server."));
102
+ // A guest token only ends when its project was claimed or deleted. claim.json is no use then.
103
+ guestEnded = err.code === TOKEN_ENDED_CODE && readClaimFile() !== null;
51
104
  }
52
105
  }
106
+ else if (brokenToken) {
107
+ console.log(pc.yellow("token.json was not valid, so its token could not be revoked from here. If it held one, revoke it in Project settings, Tokens."));
108
+ }
53
109
  const projectId = meta?.projectId ?? folder?.projectId;
54
110
  if (projectId && (await clearCachedKeys(keychainAccount(resolveApiUrl(opts.apiUrl), projectId)))) {
55
111
  console.log(`${pc.green("✓")} Removed the unlocked keys from the OS keychain`);
56
112
  }
57
113
  if (clearFolderToken())
58
114
  console.log(`${pc.green("✓")} Removed token.json`);
59
- if (fs.existsSync(projectFile)) {
115
+ if (keepProjectFile) {
116
+ console.log(pc.dim("Kept project.json. It is committed, so every clone knows its project."));
117
+ }
118
+ else if (fs.existsSync(projectFile)) {
60
119
  fs.unlinkSync(projectFile);
61
120
  console.log(`${pc.green("✓")} Removed project.json`);
62
121
  }
63
- if (readClaimFile()) {
122
+ if (guestEnded && clearClaimFile()) {
123
+ console.log(`${pc.green("✓")} Removed claim.json. The guest project was claimed or deleted, so it has no use.`);
124
+ }
125
+ else if (readClaimFile()) {
64
126
  console.log(pc.yellow("Kept claim.json. It holds the claim code and guest passphrase. Delete it if you do not need the project."));
65
127
  }
66
128
  try {
@@ -8,6 +8,8 @@ export declare function apiUrlForDisk(url: string | undefined | null): string |
8
8
  export declare function isLocalHost(hostname: string): boolean;
9
9
  /** https anywhere, http only for localhost. Returns the parsed URL or null. */
10
10
  export declare function parseSafeUrl(raw: string): URL | null;
11
+ /** GETMYENV_PUSH_API_URL replaces the Vercel or Netlify API base, for tests. */
12
+ export declare function pushApiBase(fallback: string, env?: string | undefined): string;
11
13
  /**
12
14
  * Pick the API base: --api-url, then GETMYENV_API_URL, then the gitignored
13
15
  * token.json, then production. project.json is committed, so it is never read.
@@ -36,6 +36,16 @@ export function parseSafeUrl(raw) {
36
36
  return url;
37
37
  return null;
38
38
  }
39
+ /** GETMYENV_PUSH_API_URL replaces the Vercel or Netlify API base, for tests. */
40
+ export function pushApiBase(fallback, env = process.env.GETMYENV_PUSH_API_URL) {
41
+ const raw = env?.trim();
42
+ if (!raw)
43
+ return fallback;
44
+ if (!parseSafeUrl(raw)) {
45
+ throw new Error(`Refusing GETMYENV_PUSH_API_URL ${raw}. Use https, or http://localhost for local tests.`);
46
+ }
47
+ return normalizeApiBase(raw);
48
+ }
39
49
  /**
40
50
  * Pick the API base: --api-url, then GETMYENV_API_URL, then the gitignored
41
51
  * token.json, then production. project.json is committed, so it is never read.
package/dist/lib/api.d.ts CHANGED
@@ -5,7 +5,25 @@ import type { NewMachinePolicy } from "@getmyenv/shared";
5
5
  /** From package.json, which ships with the package: src/lib and dist/lib are both two levels down. */
6
6
  export declare const CLI_VERSION: string;
7
7
  export { isDefaultApiUrl, parseSafeUrl } from "./api-url.js";
8
- export declare const API_TIMEOUT_MS = 30000;
8
+ /** Short on purpose: a stuck process keeps npx's cache lock, so a second `npx getmyenv` waits silently. */
9
+ export declare const API_TIMEOUT_MS = 8000;
10
+ /** A request still running after this prints one "Contacting" line on stderr. */
11
+ export declare const CONTACTING_AFTER_MS = 1000;
12
+ /** Error thrown when a request runs past `API_TIMEOUT_MS`, headers and body together. */
13
+ export declare class RequestTimeoutError extends Error {
14
+ constructor();
15
+ }
16
+ /**
17
+ * Runs `work` with an abort signal and a hard deadline. The deadline rejects even
18
+ * if the socket ignores the abort, and covers reading the body, not only the
19
+ * headers. Timers are cleared when `work` settles, so nothing outlives the request.
20
+ */
21
+ export declare function withDeadline<T>(work: (signal: AbortSignal) => Promise<T>, opts?: {
22
+ timeoutMs?: number;
23
+ contacting?: string;
24
+ }): Promise<T>;
25
+ /** Test hook: lets the "Contacting" line print again. */
26
+ export declare function resetContactingForTests(): void;
9
27
  export type Protection = "open" | "locked";
10
28
  export type ValueStatus = "set" | "expiring_soon" | "expired" | "missing";
11
29
  export type ContextInfo = {
@@ -45,7 +63,9 @@ export type WhoamiResponse = {
45
63
  };
46
64
  export declare class ApiError extends Error {
47
65
  status: number;
48
- constructor(message: string, status: number);
66
+ /** Machine-readable reason from the server, like `token_ended`. */
67
+ code?: string;
68
+ constructor(message: string, status: number, code?: string);
49
69
  }
50
70
  export declare function resolveApiUrl(cliOverride?: string): string;
51
71
  export declare class ApiClient {