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.
- package/README.md +25 -12
- package/dist/bin.js +8 -0
- package/dist/cli.js +13 -3
- package/dist/commands/backup.js +1 -1
- package/dist/commands/claim.js +3 -2
- package/dist/commands/context.js +4 -4
- package/dist/commands/export.js +19 -22
- package/dist/commands/guest.d.ts +1 -1
- package/dist/commands/guest.js +17 -6
- package/dist/commands/import.js +42 -10
- package/dist/commands/lock.js +1 -1
- package/dist/commands/ls.js +2 -2
- package/dist/commands/push.d.ts +25 -0
- package/dist/commands/push.js +189 -0
- package/dist/commands/restore.js +3 -3
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +63 -24
- package/dist/commands/set.d.ts +16 -0
- package/dist/commands/set.js +98 -14
- package/dist/commands/start.js +7 -7
- package/dist/commands/status.js +3 -3
- package/dist/commands/template.js +9 -6
- package/dist/commands/unlink.d.ts +14 -5
- package/dist/commands/unlink.js +79 -17
- package/dist/lib/api-url.d.ts +2 -0
- package/dist/lib/api-url.js +10 -0
- package/dist/lib/api.d.ts +22 -2
- package/dist/lib/api.js +108 -34
- package/dist/lib/authorize.js +2 -2
- package/dist/lib/bootstrap.d.ts +5 -0
- package/dist/lib/bootstrap.js +32 -8
- package/dist/lib/colors.d.ts +14 -0
- package/dist/lib/colors.js +17 -0
- package/dist/lib/confirm.d.ts +7 -3
- package/dist/lib/confirm.js +21 -12
- package/dist/lib/context.js +1 -1
- package/dist/lib/credentials.d.ts +4 -0
- package/dist/lib/credentials.js +13 -11
- package/dist/lib/fs.d.ts +36 -1
- package/dist/lib/fs.js +102 -23
- package/dist/lib/known-keys-cache.d.ts +15 -0
- package/dist/lib/known-keys-cache.js +102 -0
- package/dist/lib/known-keys.d.ts +2 -0
- package/dist/lib/known-keys.js +12 -3
- package/dist/lib/read-context.d.ts +26 -0
- package/dist/lib/read-context.js +22 -0
- package/dist/lib/recovery-code.js +1 -1
- package/dist/lib/resolve.d.ts +2 -1
- package/dist/lib/resolve.js +1 -1
- package/dist/lib/run-env.d.ts +19 -3
- package/dist/lib/run-env.js +66 -13
- package/dist/lib/template-file.js +1 -1
- package/dist/lib/update-check.js +1 -1
- package/dist/lib/upload.js +2 -2
- package/dist/lib/vault.js +1 -1
- package/dist/providers/http.d.ts +16 -0
- package/dist/providers/http.js +69 -0
- package/dist/providers/netlify.d.ts +11 -0
- package/dist/providers/netlify.js +184 -0
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/vercel.d.ts +17 -0
- package/dist/providers/vercel.js +205 -0
- package/package.json +19 -12
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import pc from "
|
|
2
|
-
import {
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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>;
|
package/dist/commands/unlink.js
CHANGED
|
@@ -1,37 +1,85 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import pc from "
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 =
|
|
14
|
-
const folder =
|
|
46
|
+
const meta = readMetaLenient();
|
|
47
|
+
const folder = readFolderTokenLenient();
|
|
48
|
+
const brokenToken = tokenFileIsBroken();
|
|
15
49
|
const projectFile = path.join(getLocalDir(), "project.json");
|
|
16
|
-
|
|
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(
|
|
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 =
|
|
26
|
-
? "
|
|
27
|
-
:
|
|
28
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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 (
|
|
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 (
|
|
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 {
|
package/dist/lib/api-url.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/api-url.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
99
|
+
const url = `${this.baseUrl.replace(/\/$/, "")}${path}`;
|
|
100
|
+
let origin = this.baseUrl;
|
|
46
101
|
try {
|
|
47
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/authorize.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import pc from "
|
|
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(`
|
|
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();
|
package/dist/lib/bootstrap.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/bootstrap.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import pc from "
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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
|
-
|
|
26
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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));
|
package/dist/lib/confirm.d.ts
CHANGED
|
@@ -4,13 +4,17 @@ export type ConflictItem = {
|
|
|
4
4
|
detail?: string;
|
|
5
5
|
};
|
|
6
6
|
/**
|
|
7
|
-
* Prompt only when
|
|
8
|
-
*
|
|
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
|
|
18
|
+
/** Skip the prompt when true. */
|
|
15
19
|
yes?: boolean;
|
|
16
20
|
}): Promise<boolean>;
|