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,9 +1,10 @@
1
- import pc from "picocolors";
2
- import { knownKey, knownKeyFormatHint, linkDomain, parseTemplateRef, } from "@getmyenv/shared";
1
+ import { pcErr as pc } from "../lib/colors.js";
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";
5
5
  import { ensureTokenAndLink } from "../lib/bootstrap.js";
6
6
  import { resolveContext } from "../lib/context.js";
7
+ import { knownKeys, 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
  /** Load a public template by id or URL. No token needed. */
@@ -61,7 +62,7 @@ export async function applyTemplateFile(file, opts) {
61
62
  const todo = missingIn(file.keys, linked, ctx);
62
63
  if (todo.length > 0) {
63
64
  console.error(`No value yet in ${ctx.slug}: ${todo.map((k) => k.name).join(", ")}`);
64
- 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`);
65
66
  }
66
67
  return { context: ctx.slug };
67
68
  }
@@ -79,10 +80,12 @@ export async function askMissingValues(keys, linked, ctx) {
79
80
  console.error(`${pc.green("✓")} Every template variable has a value in ${ctx.slug}.`);
80
81
  return;
81
82
  }
83
+ await refreshKnownKeys(linked.apiUrl);
84
+ const catalog = knownKeys();
82
85
  console.error(`Values for ${pc.bold(ctx.slug)}. Hidden as you type. Enter skips one.\n`);
83
86
  const pairs = {};
84
87
  for (const key of todo) {
85
- const known = knownKey(key.name);
88
+ const known = catalog.get(key.name);
86
89
  const description = key.description || known?.description;
87
90
  const link = key.link || known?.docsUrl;
88
91
  console.error(`${pc.bold(key.name)}${key.optional ? pc.dim(" (optional)") : ""}`);
@@ -92,7 +95,7 @@ export async function askMissingValues(keys, linked, ctx) {
92
95
  console.error(pc.dim(` Get it on ${linkDomain(link) ?? link}: ${link}`));
93
96
  const value = await promptHidden(` ${key.name}: `);
94
97
  if (value) {
95
- const hint = knownKeyFormatHint(key.name, value);
98
+ const hint = catalog.formatHint(key.name, value);
96
99
  if (hint)
97
100
  console.error(pc.yellow(` ${hint}`));
98
101
  pairs[key.name] = value;
@@ -106,7 +109,7 @@ export async function askMissingValues(keys, linked, ctx) {
106
109
  }
107
110
  const skipped = todo.filter((k) => !pairs[k.name]).map((k) => k.name);
108
111
  if (skipped.length > 0) {
109
- 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`));
110
113
  }
111
114
  console.error("");
112
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 {
package/dist/lib/api.js CHANGED
@@ -2,25 +2,79 @@
2
2
  * CLI API client. Never prints plaintext values.
3
3
  */
4
4
  import { createRequire } from "node:module";
5
- import { readFolderToken } from "./credentials.js";
5
+ import { readFolderToken, readFolderTokenLenient } from "./credentials.js";
6
6
  import { pickApiUrl } from "./api-url.js";
7
7
  /** From package.json, which ships with the package: src/lib and dist/lib are both two levels down. */
8
8
  export const CLI_VERSION = createRequire(import.meta.url)("../../package.json").version;
9
9
  export { isDefaultApiUrl, parseSafeUrl } from "./api-url.js";
10
- export const API_TIMEOUT_MS = 30_000;
10
+ /** Short on purpose: a stuck process keeps npx's cache lock, so a second `npx getmyenv` waits silently. */
11
+ export const API_TIMEOUT_MS = 8_000;
12
+ /** A request still running after this prints one "Contacting" line on stderr. */
13
+ export const CONTACTING_AFTER_MS = 1_000;
14
+ let contactingPrinted = false;
15
+ /** Error thrown when a request runs past `API_TIMEOUT_MS`, headers and body together. */
16
+ export class RequestTimeoutError extends Error {
17
+ constructor() {
18
+ super("timed out");
19
+ this.name = "TimeoutError";
20
+ }
21
+ }
22
+ /**
23
+ * Runs `work` with an abort signal and a hard deadline. The deadline rejects even
24
+ * if the socket ignores the abort, and covers reading the body, not only the
25
+ * headers. Timers are cleared when `work` settles, so nothing outlives the request.
26
+ */
27
+ export async function withDeadline(work, opts = {}) {
28
+ const timeoutMs = opts.timeoutMs ?? API_TIMEOUT_MS;
29
+ const controller = new AbortController();
30
+ let deadline;
31
+ let contacting;
32
+ const timedOut = new Promise((_, reject) => {
33
+ deadline = setTimeout(() => {
34
+ const err = new RequestTimeoutError();
35
+ controller.abort(err);
36
+ reject(err);
37
+ }, timeoutMs);
38
+ });
39
+ if (opts.contacting && !contactingPrinted) {
40
+ const origin = opts.contacting;
41
+ contacting = setTimeout(() => {
42
+ contactingPrinted = true;
43
+ process.stderr.write(`Contacting ${origin}...\n`);
44
+ }, CONTACTING_AFTER_MS);
45
+ contacting.unref?.();
46
+ }
47
+ try {
48
+ const running = work(controller.signal);
49
+ // After the deadline wins, the aborted work still rejects. Nobody awaits it then.
50
+ running.catch(() => { });
51
+ return await Promise.race([running, timedOut]);
52
+ }
53
+ finally {
54
+ clearTimeout(deadline);
55
+ clearTimeout(contacting);
56
+ }
57
+ }
58
+ /** Test hook: lets the "Contacting" line print again. */
59
+ export function resetContactingForTests() {
60
+ contactingPrinted = false;
61
+ }
11
62
  export class ApiError extends Error {
12
63
  status;
13
- constructor(message, status) {
64
+ /** Machine-readable reason from the server, like `token_ended`. */
65
+ code;
66
+ constructor(message, status, code) {
14
67
  super(message);
15
68
  this.name = "ApiError";
16
69
  this.status = status;
70
+ this.code = code;
17
71
  }
18
72
  }
19
73
  export function resolveApiUrl(cliOverride) {
20
74
  return pickApiUrl({
21
75
  override: cliOverride,
22
76
  env: process.env.GETMYENV_API_URL,
23
- tokenFile: readFolderToken()?.apiUrl,
77
+ tokenFile: readFolderTokenLenient()?.apiUrl,
24
78
  });
25
79
  }
26
80
  export class ApiClient {
@@ -42,47 +96,67 @@ export class ApiClient {
42
96
  return h;
43
97
  }
44
98
  async request(method, path, body, extraHeaders) {
45
- let res;
99
+ const url = `${this.baseUrl.replace(/\/$/, "")}${path}`;
100
+ let origin = this.baseUrl;
46
101
  try {
47
- res = await fetch(`${this.baseUrl.replace(/\/$/, "")}${path}`, {
48
- method,
49
- headers: this.headers(extraHeaders),
50
- body: body === undefined ? undefined : JSON.stringify(body),
51
- // The token must never follow a redirect to another host.
52
- redirect: "error",
53
- signal: AbortSignal.timeout(API_TIMEOUT_MS),
54
- });
55
- }
56
- catch (err) {
57
- throw networkError(err, `Could not reach ${this.baseUrl}`);
102
+ origin = new URL(url).origin;
58
103
  }
59
- if (!res.ok) {
60
- let serverMessage;
61
- try {
62
- const data = (await res.json());
63
- serverMessage = data.error;
64
- }
65
- catch {
66
- /* ignore */
67
- }
68
- const message = res.status >= 500
69
- ? `The getmyenv server had an error (HTTP ${res.status}). Try again in a moment.`
70
- : (serverMessage ?? `HTTP ${res.status}`);
71
- throw new ApiError(message, res.status);
104
+ catch {
105
+ /* keep baseUrl */
72
106
  }
73
- if (res.status === 204)
74
- return undefined;
75
107
  try {
76
- return (await res.json());
108
+ return await withDeadline(async (signal) => {
109
+ let res;
110
+ try {
111
+ res = await fetch(url, {
112
+ method,
113
+ headers: this.headers(extraHeaders),
114
+ body: body === undefined ? undefined : JSON.stringify(body),
115
+ // The token must never follow a redirect to another host.
116
+ redirect: "error",
117
+ signal,
118
+ });
119
+ }
120
+ catch (err) {
121
+ throw networkError(err, `Could not reach ${this.baseUrl}. Check your connection, or the API URL (GETMYENV_API_URL or --api-url).`);
122
+ }
123
+ if (!res.ok) {
124
+ let serverMessage;
125
+ let code;
126
+ try {
127
+ const data = (await res.json());
128
+ serverMessage = data.error;
129
+ if (typeof data.code === "string")
130
+ code = data.code;
131
+ }
132
+ catch {
133
+ /* ignore */
134
+ }
135
+ const message = res.status >= 500
136
+ ? `The getmyenv server had an error (HTTP ${res.status}). Try again in a moment.`
137
+ : (serverMessage ?? `HTTP ${res.status}`);
138
+ throw new ApiError(message, res.status, code);
139
+ }
140
+ if (res.status === 204)
141
+ return undefined;
142
+ try {
143
+ return (await res.json());
144
+ }
145
+ catch (err) {
146
+ throw networkError(err, `The connection to ${this.baseUrl} closed before the answer arrived. Try again in a moment.`);
147
+ }
148
+ }, { contacting: origin });
77
149
  }
78
150
  catch (err) {
79
- throw networkError(err, `The connection to ${this.baseUrl} closed before the answer arrived. Try again in a moment.`);
151
+ if (err instanceof RequestTimeoutError)
152
+ throw networkError(err, "");
153
+ throw err;
80
154
  }
81
155
  }
82
156
  }
83
157
  function networkError(err, fallback) {
84
158
  if (err instanceof Error && err.name === "TimeoutError") {
85
- return new ApiError(`The getmyenv server did not answer within ${API_TIMEOUT_MS / 1000} seconds. Try again in a moment.`, 0);
159
+ return new ApiError(`The getmyenv server did not answer within ${API_TIMEOUT_MS / 1000} seconds. Try again in a moment.`, 0, "timeout");
86
160
  }
87
161
  return new ApiError(fallback, 0);
88
162
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import pc from "picocolors";
2
+ import { pcErr as pc } from "./colors.js";
3
3
  import { ensureGitignoreHasEnv, readLine, readProjectMeta, writeProjectMeta, } from "./fs.js";
4
4
  import { writeFolderToken } from "./credentials.js";
5
5
  import { ApiClient, parseSafeUrl, resolveApiUrl } from "./api.js";
@@ -97,7 +97,7 @@ async function authorizeViaBrowser(apiUrl, newProject) {
97
97
  });
98
98
  console.error("Open this URL to sign in and pick a project:\n");
99
99
  console.error(` ${verifyUrl}\n`);
100
- console.error(`Check the page shows this code: ${pc.bold(start.userCode)}\n`);
100
+ console.error(`Enter this code on the page: ${pc.bold(start.userCode)}\n`);
101
101
  openBrowser(verifyUrl);
102
102
  console.error(pc.dim("Opened in your browser. Waiting...\n"));
103
103
  const deadline = new Date(start.expiresAt).getTime();
@@ -8,6 +8,11 @@ export type Linked = {
8
8
  meta: ProjectMeta | null;
9
9
  };
10
10
  export declare const NOT_LINKED_STEPS: string;
11
+ /**
12
+ * Next steps for a folder with no token on this machine. A clone with a
13
+ * committed project.json gets its project named: it is not a new folder.
14
+ */
15
+ export declare function notLinkedMessage(meta?: ProjectMeta | null): string;
11
16
  export declare function printGuestBanner(who: WhoamiResponse): void;
12
17
  export declare function fetchWhoami(client: ApiClient): Promise<WhoamiResponse>;
13
18
  /**
@@ -1,6 +1,8 @@
1
- import pc from "picocolors";
2
- import { GUEST_GRACE_MS } from "@getmyenv/shared";
3
- import { ensureGitignoreHasEnv, readClaimFile, readProjectMeta, writeProjectMeta, } from "./fs.js";
1
+ import { pcErr as pc } from "./colors.js";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { GUEST_ACTIVE_DAYS, GUEST_GRACE_MS, TEMPLATE_FILE, TOKEN_ENDED_CODE } from "@getmyenv/shared";
5
+ import { ensureGitignoreHasEnv, getLocalDir, readClaimFile, readProjectMeta, writeProjectMeta, } from "./fs.js";
4
6
  import { ApiClient, ApiError, getSessionToken, resolveApiUrl, } from "./api.js";
5
7
  import { assertTokenNotCommitted } from "./credentials.js";
6
8
  export const NOT_LINKED_STEPS = [
@@ -9,6 +11,21 @@ export const NOT_LINKED_STEPS = [
9
11
  "Without an account: npx getmyenv guest",
10
12
  "In CI: set GETMYENV_TOKEN",
11
13
  ].join("\n");
14
+ /**
15
+ * Next steps for a folder with no token on this machine. A clone with a
16
+ * committed project.json gets its project named: it is not a new folder.
17
+ */
18
+ export function notLinkedMessage(meta = readProjectMeta()) {
19
+ if (!meta?.projectId)
20
+ return NOT_LINKED_STEPS;
21
+ const hasTemplate = fs.existsSync(path.join(getLocalDir(), "template.json"));
22
+ return [
23
+ `This folder belongs to ${meta.projectName ?? meta.projectId}, but this machine has no token for it.`,
24
+ "In a terminal: npx getmyenv (sign in)",
25
+ ...(hasTemplate ? [`Or start your own project from ${TEMPLATE_FILE}: npx getmyenv guest`] : []),
26
+ "In CI: set GETMYENV_TOKEN",
27
+ ].join("\n");
28
+ }
12
29
  export function printGuestBanner(who) {
13
30
  if (!who.isGuest)
14
31
  return;
@@ -22,8 +39,12 @@ export function printGuestBanner(who) {
22
39
  return;
23
40
  }
24
41
  const daysLeft = Number.isFinite(expires) ? Math.max(0, Math.ceil((expires - Date.now()) / DAY)) : null;
25
- const days = daysLeft == null ? "soon" : plural(daysLeft);
26
- console.error(pc.yellow(`Guest project. Expires in ${days}. Keep it: npx getmyenv claim.${needClaim}\n`));
42
+ // Each use moves the date to GUEST_ACTIVE_DAYS from now, so a countdown only means
43
+ // something once the cap after creation is closer than that.
44
+ const when = daysLeft != null && daysLeft < GUEST_ACTIVE_DAYS
45
+ ? `Expires in ${plural(daysLeft)}.`
46
+ : `Expires after ${plural(GUEST_ACTIVE_DAYS)} without use.`;
47
+ console.error(pc.yellow(`Guest project. ${when} Keep it: npx getmyenv claim.${needClaim}\n`));
27
48
  }
28
49
  export async function fetchWhoami(client) {
29
50
  try {
@@ -33,6 +54,9 @@ export async function fetchWhoami(client) {
33
54
  if (err instanceof ApiError && err.status === 410) {
34
55
  throw new Error(err.message);
35
56
  }
57
+ if (err instanceof ApiError && err.status === 401 && err.code === TOKEN_ENDED_CODE) {
58
+ throw new Error(err.message);
59
+ }
36
60
  if (err instanceof ApiError && err.status === 401) {
37
61
  throw new Error(process.env.GETMYENV_TOKEN?.trim()
38
62
  ? "GETMYENV_TOKEN no longer works. It was revoked or expired. Create a new token in Project settings, Tokens."
@@ -49,14 +73,14 @@ export async function fetchWhoami(client) {
49
73
  export async function ensureTokenAndLink(opts = {}) {
50
74
  if (!getSessionToken()) {
51
75
  if (!process.stdin.isTTY) {
52
- throw new Error(NOT_LINKED_STEPS);
76
+ throw new Error(notLinkedMessage());
53
77
  }
54
78
  const { startSetup } = await import("../commands/start.js");
55
79
  await startSetup({ apiUrl: opts.apiUrl, thenContinue: true });
56
80
  }
57
81
  const token = getSessionToken();
58
82
  if (!token)
59
- throw new Error(NOT_LINKED_STEPS);
83
+ throw new Error(notLinkedMessage());
60
84
  if (!process.env.GETMYENV_TOKEN?.trim())
61
85
  assertTokenNotCommitted();
62
86
  const apiUrl = resolveApiUrl(opts.apiUrl);
@@ -68,7 +92,7 @@ export async function ensureTokenAndLink(opts = {}) {
68
92
  if (meta?.projectId && meta.projectId !== who.project.id) {
69
93
  throw new Error([
70
94
  `project.json points at ${meta.projectName ?? meta.projectId}, but the token is for ${who.project.name}.`,
71
- "Run: npx getmyenv unlink (then run the command again)",
95
+ "Run: npx getmyenv unlink, then run the command again.",
72
96
  ].join("\n"));
73
97
  }
74
98
  const usingEnvToken = Boolean(process.env.GETMYENV_TOKEN?.trim());
@@ -0,0 +1,14 @@
1
+ type Stream = {
2
+ isTTY?: boolean;
3
+ };
4
+ /**
5
+ * Color only for a terminal. picocolors alone turns color on for every Windows
6
+ * process, so piped or redirected output would hold escape codes.
7
+ * NO_COLOR turns it off, FORCE_COLOR turns it on.
8
+ */
9
+ export declare function colorEnabled(stream: Stream, env?: Record<string, string | undefined>): boolean;
10
+ /** Colors for text written to stdout. */
11
+ export declare const pc: import("picocolors/types").Colors;
12
+ /** Colors for text written to stderr: prompts, status lines and warnings. */
13
+ export declare const pcErr: import("picocolors/types").Colors;
14
+ export {};
@@ -0,0 +1,17 @@
1
+ import picocolors from "picocolors";
2
+ /**
3
+ * Color only for a terminal. picocolors alone turns color on for every Windows
4
+ * process, so piped or redirected output would hold escape codes.
5
+ * NO_COLOR turns it off, FORCE_COLOR turns it on.
6
+ */
7
+ export function colorEnabled(stream, env = process.env) {
8
+ if (env.NO_COLOR)
9
+ return false;
10
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== "0")
11
+ return true;
12
+ return Boolean(stream.isTTY) && env.TERM !== "dumb";
13
+ }
14
+ /** Colors for text written to stdout. */
15
+ export const pc = picocolors.createColors(colorEnabled(process.stdout));
16
+ /** Colors for text written to stderr: prompts, status lines and warnings. */
17
+ export const pcErr = picocolors.createColors(colorEnabled(process.stderr));
@@ -4,13 +4,17 @@ export type ConflictItem = {
4
4
  detail?: string;
5
5
  };
6
6
  /**
7
- * Prompt only when there are conflicts. Returns true to proceed.
8
- * Non-TTY: requires `yes` to be true when conflicts exist.
7
+ * Prompt only when something would be lost. Returns true to proceed.
8
+ * `conflicts` are names whose value changes, `removed` names that go away.
9
+ * Each list is counted on its own. Non-TTY: requires `yes`.
9
10
  */
10
11
  export declare function confirmDiff(opts: {
11
12
  conflicts: ConflictItem[];
13
+ removed?: ConflictItem[];
14
+ /** Heading after the count of `removed`. */
15
+ removedLabel?: string;
12
16
  /** What will happen if the user confirms, e.g. "Overwrite local values". */
13
17
  action: string;
14
- /** Skip the prompt when true (still returns false if conflicts and non-TTY without yes). */
18
+ /** Skip the prompt when true. */
15
19
  yes?: boolean;
16
20
  }): Promise<boolean>;