getmyenv 0.13.0 → 0.14.1
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 +14 -12
- package/dist/bin.js +8 -0
- package/dist/cli.js +11 -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 +17 -11
- package/dist/commands/guest.d.ts +1 -1
- package/dist/commands/guest.js +19 -6
- package/dist/commands/import.js +38 -9
- package/dist/commands/lock.js +1 -1
- package/dist/commands/ls.js +2 -2
- package/dist/commands/push.js +6 -5
- package/dist/commands/restore.js +3 -3
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +61 -12
- package/dist/commands/set.d.ts +16 -0
- package/dist/commands/set.js +91 -12
- package/dist/commands/start.js +7 -7
- package/dist/commands/status.js +3 -3
- package/dist/commands/template.js +3 -3
- 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 +107 -28
- package/dist/lib/known-keys-cache.d.ts +3 -2
- package/dist/lib/known-keys-cache.js +19 -5
- package/dist/lib/known-keys.js +1 -1
- package/dist/lib/recovery-code.js +1 -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.js +15 -12
- package/dist/providers/netlify.js +3 -1
- package/dist/providers/vercel.js +3 -1
- package/package.json +19 -12
package/dist/commands/set.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/set.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import pc from "
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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}.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
104
|
-
console.log(pc.dim(
|
|
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
|
}
|
package/dist/commands/start.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import pc from "
|
|
4
|
-
import { DEFAULT_CONTEXT_SLUG, FACTS,
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
234
|
-
|
|
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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import pc from "
|
|
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
|
|
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("
|
|
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 "
|
|
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(
|
|
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
|
|
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>;
|
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 {
|