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