getmyenv 0.12.2 → 0.13.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 +12 -1
- package/dist/cli.js +2 -0
- package/dist/commands/export.js +2 -11
- package/dist/commands/import.js +4 -1
- package/dist/commands/push.d.ts +25 -0
- package/dist/commands/push.js +188 -0
- package/dist/commands/run.js +5 -15
- package/dist/commands/set.js +7 -2
- package/dist/commands/template.js +6 -3
- package/dist/lib/known-keys-cache.d.ts +14 -0
- package/dist/lib/known-keys-cache.js +90 -0
- package/dist/lib/known-keys.d.ts +2 -0
- package/dist/lib/known-keys.js +11 -2
- package/dist/lib/read-context.d.ts +26 -0
- package/dist/lib/read-context.js +22 -0
- package/dist/lib/resolve.d.ts +2 -1
- package/dist/providers/http.d.ts +16 -0
- package/dist/providers/http.js +66 -0
- package/dist/providers/netlify.d.ts +11 -0
- package/dist/providers/netlify.js +182 -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 +203 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ my-app/
|
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
<!-- cli:help -->
|
|
52
|
-
Output of `npx getmyenv --help` (0.
|
|
52
|
+
Output of `npx getmyenv --help` (0.13.0):
|
|
53
53
|
|
|
54
54
|
```text
|
|
55
55
|
Usage: getmyenv [options] [command]
|
|
@@ -77,6 +77,8 @@ Commands:
|
|
|
77
77
|
goes to that context)
|
|
78
78
|
export [options] Write a context to a plaintext .env file, for
|
|
79
79
|
tools that need one
|
|
80
|
+
push [options] <provider> Copy a context to Vercel or Netlify with your
|
|
81
|
+
platform token (VERCEL_TOKEN, NETLIFY_AUTH_TOKEN)
|
|
80
82
|
guest [options] Create a guest project without an account. It
|
|
81
83
|
works for 30 days. Claim it within 60.
|
|
82
84
|
claim [options] Claim a guest project (reads .getmyenv/claim.json,
|
|
@@ -118,6 +120,15 @@ export GETMYENV_TOKEN=... # server token from the dashboard
|
|
|
118
120
|
npx getmyenv run -- node server.js
|
|
119
121
|
```
|
|
120
122
|
|
|
123
|
+
Windows PowerShell:
|
|
124
|
+
|
|
125
|
+
```powershell
|
|
126
|
+
$env:GETMYENV_TOKEN="..."
|
|
127
|
+
npx getmyenv run -- node server.js
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Check it works: `npx getmyenv run --explain` lists each name and where its value comes from. Never values.
|
|
131
|
+
|
|
121
132
|
Server tokens are read-only. `set` needs the folder token from browser sign-in, or a guest project. `printf %s "$VALUE" | npx getmyenv set API_KEY --stdin` keeps the value out of argv.
|
|
122
133
|
|
|
123
134
|
- `GETMYENV_TOKEN`: a server token from Project settings, Tokens.
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { contextCreateCommand, contextListCommand, contextUseCommand, } from "./
|
|
|
6
6
|
import { exportCommand } from "./commands/export.js";
|
|
7
7
|
import { guestCommand } from "./commands/guest.js";
|
|
8
8
|
import { importCommand } from "./commands/import.js";
|
|
9
|
+
import { pushCommand } from "./commands/push.js";
|
|
9
10
|
import { lockCommand } from "./commands/lock.js";
|
|
10
11
|
import { lsCommand } from "./commands/ls.js";
|
|
11
12
|
import { restoreCommand } from "./commands/restore.js";
|
|
@@ -88,6 +89,7 @@ export function buildProgram(argv) {
|
|
|
88
89
|
define(program, "ls").action((opts) => lsCommand(opts));
|
|
89
90
|
define(program, "import").action((file, opts) => importCommand(file, opts));
|
|
90
91
|
define(program, "export").action((opts) => exportCommand(opts));
|
|
92
|
+
define(program, "push").action((provider, opts) => pushCommand(provider, opts));
|
|
91
93
|
define(program, "guest").action((opts) => guestCommand(opts));
|
|
92
94
|
define(program, "claim").action((opts) => claimCommand(opts));
|
|
93
95
|
define(program, "backup").action((opts) => backupCommand(opts));
|
package/dist/commands/export.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
-
import { decryptSecret, parsePayload } from "@getmyenv/crypto";
|
|
4
3
|
import { EXPORT_WARNING, formatDotenvValue } from "@getmyenv/shared";
|
|
5
4
|
import { ensureGitignoreHas, ensureGitignoreHasEnv, parseEnvFile, writePrivateFile, } from "../lib/fs.js";
|
|
6
5
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
7
6
|
import { resolveContext } from "../lib/context.js";
|
|
8
7
|
import { confirmDiff } from "../lib/confirm.js";
|
|
8
|
+
import { decryptContext } from "../lib/read-context.js";
|
|
9
9
|
import { resolveValues } from "../lib/resolve.js";
|
|
10
|
-
import { contextKey } from "../lib/vault.js";
|
|
11
|
-
import { assertVariableNames } from "../lib/run-env.js";
|
|
12
10
|
/**
|
|
13
11
|
* `export`: write one context to a plaintext file, for tools that need one.
|
|
14
12
|
* Read-only contexts go through the same checks as `run`: server token,
|
|
@@ -22,14 +20,7 @@ export async function exportCommand(opts) {
|
|
|
22
20
|
const data = ctx.protection === "locked"
|
|
23
21
|
? await resolveValues(linked.client, linked.who.project.id, ctx.slug, "export")
|
|
24
22
|
: await linked.client.request("GET", `/api/cli/values?context=${encodeURIComponent(ctx.slug)}`);
|
|
25
|
-
|
|
26
|
-
const remote = {};
|
|
27
|
-
if (data.values.length > 0) {
|
|
28
|
-
const key = await contextKey(linked, { ...ctx, sealedKey: data.sealedKey });
|
|
29
|
-
for (const v of data.values) {
|
|
30
|
-
remote[v.name] = decryptSecret(key, v.name, parsePayload(v.ciphertext));
|
|
31
|
-
}
|
|
32
|
-
}
|
|
23
|
+
const { values: remote } = await decryptContext(linked, ctx, data);
|
|
33
24
|
const lines = [
|
|
34
25
|
`# Exported by getmyenv from ${ctx.slug}. Plaintext. Do not commit.`,
|
|
35
26
|
...Object.entries(remote).map(([k, v]) => `${k}=${formatDotenvValue(v)}`),
|
package/dist/commands/import.js
CHANGED
|
@@ -6,7 +6,8 @@ import { contextSlugFromEnvFile, ensureGitignoreHasEnv, parseEnvFile, promptConf
|
|
|
6
6
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
7
7
|
import { defaultContextSlug, findContext, resolveContext } from "../lib/context.js";
|
|
8
8
|
import { confirmDiff } from "../lib/confirm.js";
|
|
9
|
-
import { printDidYouMean } from "../lib/known-keys.js";
|
|
9
|
+
import { printDidYouMean, printPublicSecretWarnings } from "../lib/known-keys.js";
|
|
10
|
+
import { refreshKnownKeys } from "../lib/known-keys-cache.js";
|
|
10
11
|
import { syncFromLinked } from "../lib/template-file.js";
|
|
11
12
|
import { uploadValues } from "../lib/upload.js";
|
|
12
13
|
import { runLine } from "./start.js";
|
|
@@ -69,6 +70,7 @@ async function ensureTargetContext(linked, slug, opts) {
|
|
|
69
70
|
/** `import [file]`: encrypt .env values locally and upload them to contexts. */
|
|
70
71
|
export async function importCommand(file, opts) {
|
|
71
72
|
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
73
|
+
await refreshKnownKeys(linked.apiUrl);
|
|
72
74
|
const { who, meta } = linked;
|
|
73
75
|
const explicitContext = opts.context ? resolveContext(who, meta, opts.context).slug : undefined;
|
|
74
76
|
let files;
|
|
@@ -131,6 +133,7 @@ export async function importCommand(file, opts) {
|
|
|
131
133
|
console.error(` ${opts.prune ? pc.red("-") : pc.dim("=")} ${name}${pc.dim(opts.prune ? " (unset, not in file)" : " (kept, not in file)")}`);
|
|
132
134
|
}
|
|
133
135
|
printDidYouMean(names, " ");
|
|
136
|
+
printPublicSecretWarnings(pairs, " ");
|
|
134
137
|
if (opts.dryRun) {
|
|
135
138
|
console.error("");
|
|
136
139
|
continue;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PlannedVar, PushAdapter, PushProviderId } from "../providers/types.js";
|
|
2
|
+
export type PushOpts = {
|
|
3
|
+
context?: string;
|
|
4
|
+
apiUrl?: string;
|
|
5
|
+
project?: string;
|
|
6
|
+
team?: string;
|
|
7
|
+
site?: string;
|
|
8
|
+
target?: string;
|
|
9
|
+
prune?: boolean;
|
|
10
|
+
dryRun?: boolean;
|
|
11
|
+
yes?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function createAdapter(provider: PushProviderId, token: string, opts: PushOpts): PushAdapter;
|
|
14
|
+
/** Names-only plan. Values never leave this function except to the platform. */
|
|
15
|
+
export declare function buildPlan(adapter: PushAdapter, values: Record<string, string>, remote: string[], prune: boolean): {
|
|
16
|
+
upserts: PlannedVar[];
|
|
17
|
+
prune: string[];
|
|
18
|
+
reserved: string[];
|
|
19
|
+
};
|
|
20
|
+
export declare function formatPlan(upserts: PlannedVar[], prune: string[]): string[];
|
|
21
|
+
/**
|
|
22
|
+
* `push vercel|netlify`: decrypt one context on this machine and write it to
|
|
23
|
+
* the platform with the user's own token. The token never reaches getmyenv.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pushCommand(providerArg: string, opts: PushOpts): Promise<void>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
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 tokenEnv = PUSH_TOKEN_ENV[provider];
|
|
83
|
+
const token = process.env[tokenEnv]?.trim();
|
|
84
|
+
if (!token) {
|
|
85
|
+
throw new Error(`Set ${tokenEnv} to your ${title} token. Create one at ${TOKEN_URLS[provider]}`);
|
|
86
|
+
}
|
|
87
|
+
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
88
|
+
if (linked.who.isGuest) {
|
|
89
|
+
throw new Error("Push needs an account. Claim this project first: npx getmyenv claim");
|
|
90
|
+
}
|
|
91
|
+
const ctx = resolveContext(linked.who, linked.meta, opts.context);
|
|
92
|
+
const adapter = createAdapter(provider, token, { ...opts, target });
|
|
93
|
+
const dest = await adapter.destination();
|
|
94
|
+
const where = describePushDestination(dest);
|
|
95
|
+
const read = await readContextValues(linked, ctx.slug, "push");
|
|
96
|
+
if (Object.keys(read.values).length === 0 || !read.key) {
|
|
97
|
+
throw new Error(`${ctx.slug} has no values to push.`);
|
|
98
|
+
}
|
|
99
|
+
const contextKey = read.key;
|
|
100
|
+
const { skip, warnings } = adapter.check(read.values);
|
|
101
|
+
const skipNames = new Set(skip.map((s) => s.name));
|
|
102
|
+
const values = Object.fromEntries(Object.entries(read.values).filter(([n]) => !skipNames.has(n)));
|
|
103
|
+
const remote = await adapter.remoteNames();
|
|
104
|
+
const plan = buildPlan(adapter, values, remote, Boolean(opts.prune));
|
|
105
|
+
const pushed = Object.fromEntries(plan.upserts.map((u) => [u.name, u.value]));
|
|
106
|
+
if (plan.upserts.length === 0)
|
|
107
|
+
throw new Error(`Nothing to push from ${ctx.slug}.`);
|
|
108
|
+
console.log(`${pc.bold(ctx.slug)} to ${where}`);
|
|
109
|
+
for (const line of formatPlan(plan.upserts, plan.prune))
|
|
110
|
+
console.log(line);
|
|
111
|
+
console.log("");
|
|
112
|
+
if (plan.reserved.length > 0) {
|
|
113
|
+
console.log(pc.dim(`Not pushed, ${dest.title} sets or reserves these names: ${plan.reserved.join(", ")}`));
|
|
114
|
+
}
|
|
115
|
+
printSkipped("Not pushed:", skip);
|
|
116
|
+
if (read.missing.length > 0) {
|
|
117
|
+
console.log(pc.dim(`No value in ${ctx.slug}, not pushed: ${read.missing.map((m) => m.name).join(", ")}`));
|
|
118
|
+
}
|
|
119
|
+
for (const w of warnings)
|
|
120
|
+
console.log(pc.yellow(w));
|
|
121
|
+
for (const u of plan.upserts) {
|
|
122
|
+
const warn = publicSecretWarning(u.name, u.value);
|
|
123
|
+
if (warn)
|
|
124
|
+
console.log(pc.yellow(warn));
|
|
125
|
+
}
|
|
126
|
+
for (const note of adapter.planNotes(plan.upserts))
|
|
127
|
+
console.log(pc.dim(note));
|
|
128
|
+
if (opts.dryRun) {
|
|
129
|
+
const past = await linked.client
|
|
130
|
+
.request("GET", `/api/cli/push?context=${encodeURIComponent(ctx.slug)}`)
|
|
131
|
+
.catch(() => ({ pushes: [] }));
|
|
132
|
+
const last = past.pushes.find((p) => p.provider === dest.provider && p.externalId === dest.externalId && p.target === dest.target);
|
|
133
|
+
if (last) {
|
|
134
|
+
const same = last.fingerprint === pushFingerprint(contextKey, pushed);
|
|
135
|
+
console.log(same
|
|
136
|
+
? `No changes in getmyenv since the last push to ${where}.`
|
|
137
|
+
: `Changed in getmyenv since the last push to ${where}.`);
|
|
138
|
+
}
|
|
139
|
+
console.log(pc.dim("Dry run. Nothing was written."));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const question = `Push ${variables(plan.upserts.length)} to ${where}?`;
|
|
143
|
+
if (plan.prune.length > 0) {
|
|
144
|
+
console.log(pc.red(`Removes from ${where}: ${plan.prune.join(", ")}`));
|
|
145
|
+
}
|
|
146
|
+
if (!opts.yes) {
|
|
147
|
+
if (!process.stdin.isTTY)
|
|
148
|
+
throw new Error(`${question} Re-run with --yes to confirm.`);
|
|
149
|
+
if (!(await promptConfirm(question, { defaultYes: false }))) {
|
|
150
|
+
console.log(pc.dim("Nothing was written."));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const result = await adapter.apply(plan.upserts, plan.prune);
|
|
155
|
+
const readable = result.written.filter((w) => w.type === "readable").length;
|
|
156
|
+
const sensitive = result.written.length - readable;
|
|
157
|
+
if (result.written.length > 0) {
|
|
158
|
+
console.log(`${pc.green("✓")} Pushed ${variables(result.written.length)} to ${where}. Readable: ${readable}. Sensitive: ${sensitive}.`);
|
|
159
|
+
console.log(`That copy lives in ${dest.title}.`);
|
|
160
|
+
console.log("Future changes in getmyenv are not pushed automatically.");
|
|
161
|
+
}
|
|
162
|
+
if (result.pruned.length > 0)
|
|
163
|
+
console.log(`Removed: ${result.pruned.join(", ")}`);
|
|
164
|
+
for (const note of result.notes)
|
|
165
|
+
console.log(pc.yellow(note));
|
|
166
|
+
printSkipped("Not pruned:", result.notPruned);
|
|
167
|
+
printSkipped("Not pushed:", result.notWritten);
|
|
168
|
+
if (result.written.length > 0) {
|
|
169
|
+
const written = Object.fromEntries(result.written.map((w) => [w.name, pushed[w.name]]));
|
|
170
|
+
try {
|
|
171
|
+
await linked.client.request("POST", "/api/cli/push", {
|
|
172
|
+
context: ctx.slug,
|
|
173
|
+
provider: dest.provider,
|
|
174
|
+
target: dest.target,
|
|
175
|
+
externalId: dest.externalId,
|
|
176
|
+
externalName: dest.externalName,
|
|
177
|
+
ownerSlug: dest.ownerSlug,
|
|
178
|
+
names: Object.keys(written).sort(),
|
|
179
|
+
fingerprint: pushFingerprint(contextKey, written),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.log(pc.yellow(`The push worked, but getmyenv could not record it: ${err.message}`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (result.notWritten.length > 0)
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import pc from "picocolors";
|
|
4
|
-
import { decryptSecret, parsePayload } from "@getmyenv/crypto";
|
|
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 { assertVariableNames, explainLines, mergeEnv, runContextLabel, signalExitCode, validationError, windowsCommandLine, } from "../lib/run-env.js";
|
|
7
|
+
import { readContextValues } from "../lib/read-context.js";
|
|
8
|
+
import { explainLines, mergeEnv, runContextLabel, signalExitCode, validationError, windowsCommandLine, } from "../lib/run-env.js";
|
|
11
9
|
/** Status lines go to stderr so the child's stdout stays clean for pipes. */
|
|
12
10
|
const log = (line = "") => console.error(line);
|
|
13
11
|
/** Resolve, validate, merge, spawn. */
|
|
@@ -16,18 +14,10 @@ export async function runCommand(opts, childArgs) {
|
|
|
16
14
|
throw new Error("Usage: npx getmyenv run [context] -- <command>");
|
|
17
15
|
}
|
|
18
16
|
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
19
|
-
const { who,
|
|
17
|
+
const { who, meta } = linked;
|
|
20
18
|
const ctx = resolveContext(who, meta, opts.context);
|
|
21
|
-
const res = await
|
|
22
|
-
|
|
23
|
-
const values = {};
|
|
24
|
-
if (res.values.length > 0) {
|
|
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);
|
|
19
|
+
const res = await readContextValues(linked, ctx.slug, "run");
|
|
20
|
+
const merged = mergeEnv(process.env, res.values, res.missing, opts.keepExisting === true);
|
|
31
21
|
const label = runContextLabel(res.context);
|
|
32
22
|
if (opts.explain) {
|
|
33
23
|
log(pc.bold(`${who.project.name} / ${label}`));
|
package/dist/commands/set.js
CHANGED
|
@@ -3,7 +3,8 @@ import { 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() {
|
|
@@ -75,7 +76,11 @@ export async function setCommand(args, opts) {
|
|
|
75
76
|
console.log(`${pc.green("✓")} ${name} ${pc.dim(`set in ${ctx.slug}`)}`);
|
|
76
77
|
for (const name of res.declared)
|
|
77
78
|
console.log(`${pc.yellow("○")} ${name} ${pc.dim(`declared, no value in ${ctx.slug}`)}`);
|
|
78
|
-
|
|
79
|
+
const newNames = pairs.map((p) => p.name).filter((n) => !linked.who.keys.some((k) => k.name === n));
|
|
80
|
+
if (newNames.length > 0)
|
|
81
|
+
await refreshKnownKeys(linked.apiUrl);
|
|
82
|
+
printDidYouMean(newNames);
|
|
83
|
+
printPublicSecretWarnings(values);
|
|
79
84
|
syncFromLinked(linked, { added: [...res.set, ...res.declared] });
|
|
80
85
|
}
|
|
81
86
|
export async function unsetCommand(names, opts) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
-
import {
|
|
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. */
|
|
@@ -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;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff additions to the known keys list, cached in the config dir and
|
|
3
|
+
* refreshed once a day. Any failure keeps the bundled list.
|
|
4
|
+
*/
|
|
5
|
+
import { type KnownKeyIndex } from "@getmyenv/shared";
|
|
6
|
+
/** The bundled list plus the cached additions. No network. */
|
|
7
|
+
export declare function knownKeys(): KnownKeyIndex;
|
|
8
|
+
/**
|
|
9
|
+
* Refresh the cache when it is older than a day or from another server.
|
|
10
|
+
* Waits at most 2 seconds. Never throws.
|
|
11
|
+
*/
|
|
12
|
+
export declare function refreshKnownKeys(apiUrl: string): Promise<void>;
|
|
13
|
+
/** Tests only. */
|
|
14
|
+
export declare function resetKnownKeysForTests(): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staff additions to the known keys list, cached in the config dir and
|
|
3
|
+
* refreshed once a day. Any failure keeps the bundled list.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { BUNDLED_KNOWN_KEYS, createKnownKeyIndex, KNOWN_KEYS } from "@getmyenv/shared";
|
|
8
|
+
import { getConfigDir } from "./fs.js";
|
|
9
|
+
const CACHE_FILE = "known-keys.json";
|
|
10
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const FETCH_TIMEOUT_MS = 2000;
|
|
12
|
+
const MAX_BODY_CHARS = 2 * 1024 * 1024;
|
|
13
|
+
let index = null;
|
|
14
|
+
function cachePath() {
|
|
15
|
+
return path.join(getConfigDir(), CACHE_FILE);
|
|
16
|
+
}
|
|
17
|
+
function readCache() {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(fs.readFileSync(cachePath(), "utf8"));
|
|
20
|
+
if (typeof data.apiUrl !== "string" || typeof data.checkedAt !== "string" || !Array.isArray(data.entries)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
apiUrl: data.apiUrl,
|
|
25
|
+
etag: typeof data.etag === "string" ? data.etag : null,
|
|
26
|
+
checkedAt: data.checkedAt,
|
|
27
|
+
entries: data.entries,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function writeCache(data) {
|
|
35
|
+
try {
|
|
36
|
+
fs.writeFileSync(cachePath(), JSON.stringify(data), { mode: 0o600 });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* keep going without a cache */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The bundled list plus the cached additions. No network. */
|
|
43
|
+
export function knownKeys() {
|
|
44
|
+
if (index)
|
|
45
|
+
return index;
|
|
46
|
+
const cached = readCache();
|
|
47
|
+
index = cached?.entries.length ? createKnownKeyIndex(KNOWN_KEYS, cached.entries) : BUNDLED_KNOWN_KEYS;
|
|
48
|
+
return index;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Refresh the cache when it is older than a day or from another server.
|
|
52
|
+
* Waits at most 2 seconds. Never throws.
|
|
53
|
+
*/
|
|
54
|
+
export async function refreshKnownKeys(apiUrl) {
|
|
55
|
+
if (!apiUrl)
|
|
56
|
+
return;
|
|
57
|
+
const cached = readCache();
|
|
58
|
+
const same = cached && cached.apiUrl === apiUrl ? cached : null;
|
|
59
|
+
if (same && Date.now() - new Date(same.checkedAt).getTime() < CACHE_TTL_MS)
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(`${apiUrl}/api/known-keys`, {
|
|
63
|
+
headers: same?.etag ? { "If-None-Match": same.etag } : {},
|
|
64
|
+
redirect: "error",
|
|
65
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
66
|
+
});
|
|
67
|
+
const checkedAt = new Date().toISOString();
|
|
68
|
+
if (res.status === 304 && same) {
|
|
69
|
+
writeCache({ ...same, checkedAt });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
return;
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
if (text.length > MAX_BODY_CHARS)
|
|
76
|
+
return;
|
|
77
|
+
const data = JSON.parse(text);
|
|
78
|
+
if (!Array.isArray(data.entries))
|
|
79
|
+
return;
|
|
80
|
+
writeCache({ apiUrl, etag: res.headers.get("etag"), checkedAt, entries: data.entries });
|
|
81
|
+
index = null;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* fail open: bundled or stale cache */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Tests only. */
|
|
88
|
+
export function resetKnownKeysForTests() {
|
|
89
|
+
index = null;
|
|
90
|
+
}
|
package/dist/lib/known-keys.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
/** "Did you mean OPENAI_API_KEY?" for names close to a known one. Nothing is renamed. */
|
|
2
2
|
export declare function didYouMean(name: string): string | null;
|
|
3
3
|
export declare function printDidYouMean(names: string[], indent?: string): void;
|
|
4
|
+
/** A public prefix (NEXT_PUBLIC_, VITE_, ...) on a value that looks secret. Nothing is blocked. */
|
|
5
|
+
export declare function printPublicSecretWarnings(pairs: Record<string, string>, indent?: string): void;
|
package/dist/lib/known-keys.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
-
import {
|
|
2
|
+
import { publicSecretWarning } from "@getmyenv/shared";
|
|
3
|
+
import { knownKeys } from "./known-keys-cache.js";
|
|
3
4
|
/** "Did you mean OPENAI_API_KEY?" for names close to a known one. Nothing is renamed. */
|
|
4
5
|
export function didYouMean(name) {
|
|
5
|
-
const near =
|
|
6
|
+
const near = knownKeys().nearMiss(name);
|
|
6
7
|
if (!near)
|
|
7
8
|
return null;
|
|
8
9
|
const why = near.source === "sdk" ? `${near.provider} SDKs read that name.` : "That is the common name.";
|
|
@@ -15,3 +16,11 @@ export function printDidYouMean(names, indent = "") {
|
|
|
15
16
|
console.error(pc.yellow(`${indent}${name}: ${hint}`));
|
|
16
17
|
}
|
|
17
18
|
}
|
|
19
|
+
/** A public prefix (NEXT_PUBLIC_, VITE_, ...) on a value that looks secret. Nothing is blocked. */
|
|
20
|
+
export function printPublicSecretWarnings(pairs, indent = "") {
|
|
21
|
+
for (const [name, value] of Object.entries(pairs)) {
|
|
22
|
+
const warn = publicSecretWarning(name, value);
|
|
23
|
+
if (warn)
|
|
24
|
+
console.error(pc.yellow(`${indent}${warn}`));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type SealedKey } from "@getmyenv/crypto";
|
|
2
|
+
import type { ContextInfo } from "./api.js";
|
|
3
|
+
import type { Linked } from "./bootstrap.js";
|
|
4
|
+
import { type ResolveOk, type ResolvePurpose } from "./resolve.js";
|
|
5
|
+
import { type MissingKey } from "./run-env.js";
|
|
6
|
+
export type ContextData = {
|
|
7
|
+
sealedKey: SealedKey | null;
|
|
8
|
+
values: Array<{
|
|
9
|
+
name: string;
|
|
10
|
+
ciphertext: string;
|
|
11
|
+
}>;
|
|
12
|
+
missing: MissingKey[];
|
|
13
|
+
};
|
|
14
|
+
export type DecryptedContext = {
|
|
15
|
+
values: Record<string, string>;
|
|
16
|
+
missing: MissingKey[];
|
|
17
|
+
/** Null when the context has no values yet. */
|
|
18
|
+
key: Uint8Array | null;
|
|
19
|
+
};
|
|
20
|
+
/** Check names, open the context key and decrypt every value. Runs on this machine only. */
|
|
21
|
+
export declare function decryptContext(linked: Linked, ctx: ContextInfo, data: ContextData): Promise<DecryptedContext>;
|
|
22
|
+
/** Resolve one context (approval for Read-only ones, as `run` does) and decrypt it. */
|
|
23
|
+
export declare function readContextValues(linked: Linked, slug: string, purpose: ResolvePurpose): Promise<DecryptedContext & {
|
|
24
|
+
context: ResolveOk["context"];
|
|
25
|
+
via: ResolveOk["via"];
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { decryptSecret, parsePayload } from "@getmyenv/crypto";
|
|
2
|
+
import { resolveValues } from "./resolve.js";
|
|
3
|
+
import { assertVariableNames } from "./run-env.js";
|
|
4
|
+
import { contextKey } from "./vault.js";
|
|
5
|
+
/** Check names, open the context key and decrypt every value. Runs on this machine only. */
|
|
6
|
+
export async function decryptContext(linked, ctx, data) {
|
|
7
|
+
assertVariableNames([...data.values.map((v) => v.name), ...data.missing.map((m) => m.name)]);
|
|
8
|
+
const values = {};
|
|
9
|
+
if (data.values.length === 0)
|
|
10
|
+
return { values, missing: data.missing, key: null };
|
|
11
|
+
const key = await contextKey(linked, { ...ctx, sealedKey: data.sealedKey });
|
|
12
|
+
for (const v of data.values) {
|
|
13
|
+
values[v.name] = decryptSecret(key, v.name, parsePayload(v.ciphertext));
|
|
14
|
+
}
|
|
15
|
+
return { values, missing: data.missing, key };
|
|
16
|
+
}
|
|
17
|
+
/** Resolve one context (approval for Read-only ones, as `run` does) and decrypt it. */
|
|
18
|
+
export async function readContextValues(linked, slug, purpose) {
|
|
19
|
+
const res = await resolveValues(linked.client, linked.who.project.id, slug, purpose);
|
|
20
|
+
const decrypted = await decryptContext(linked, res.context, res);
|
|
21
|
+
return { ...decrypted, context: res.context, via: res.via };
|
|
22
|
+
}
|
package/dist/lib/resolve.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export type ResolveOk = {
|
|
|
18
18
|
}>;
|
|
19
19
|
missing: MissingKey[];
|
|
20
20
|
};
|
|
21
|
+
export type ResolvePurpose = "run" | "export" | "push";
|
|
21
22
|
/**
|
|
22
23
|
* Ciphertext for one context. Read-only contexts need a server token, an
|
|
23
24
|
* allowed IP, and a trusted machine or an approval in the dashboard.
|
|
24
25
|
*/
|
|
25
|
-
export declare function resolveValues(client: ApiClient, projectId: string, slug: string, purpose:
|
|
26
|
+
export declare function resolveValues(client: ApiClient, projectId: string, slug: string, purpose: ResolvePurpose): Promise<ResolveOk>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare class PlatformError extends Error {
|
|
2
|
+
status: number;
|
|
3
|
+
constructor(message: string, status: number);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* One request to a platform API with the user's own token. No redirects,
|
|
7
|
+
* fixed timeout. Error messages never include values we sent.
|
|
8
|
+
*/
|
|
9
|
+
export declare function platformRequest<T>(opts: {
|
|
10
|
+
title: string;
|
|
11
|
+
base: string;
|
|
12
|
+
token: string;
|
|
13
|
+
method: "GET" | "POST" | "PATCH" | "DELETE";
|
|
14
|
+
path: string;
|
|
15
|
+
body?: unknown;
|
|
16
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { API_TIMEOUT_MS } from "../lib/api.js";
|
|
2
|
+
export class PlatformError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function errorMessage(body) {
|
|
10
|
+
if (!body || typeof body !== "object")
|
|
11
|
+
return null;
|
|
12
|
+
const b = body;
|
|
13
|
+
if (typeof b.message === "string")
|
|
14
|
+
return b.message;
|
|
15
|
+
if (b.error && typeof b.error === "object") {
|
|
16
|
+
const e = b.error;
|
|
17
|
+
if (typeof e.message === "string")
|
|
18
|
+
return e.message;
|
|
19
|
+
if (typeof e.code === "string")
|
|
20
|
+
return e.code;
|
|
21
|
+
}
|
|
22
|
+
if (typeof b.error === "string")
|
|
23
|
+
return b.error;
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One request to a platform API with the user's own token. No redirects,
|
|
28
|
+
* fixed timeout. Error messages never include values we sent.
|
|
29
|
+
*/
|
|
30
|
+
export async function platformRequest(opts) {
|
|
31
|
+
let res;
|
|
32
|
+
try {
|
|
33
|
+
res = await fetch(`${opts.base}${opts.path}`, {
|
|
34
|
+
method: opts.method,
|
|
35
|
+
headers: {
|
|
36
|
+
authorization: `Bearer ${opts.token}`,
|
|
37
|
+
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
38
|
+
},
|
|
39
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
40
|
+
redirect: "error",
|
|
41
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
const why = err instanceof Error && err.name === "TimeoutError" ? "timed out" : "is unreachable";
|
|
46
|
+
throw new PlatformError(`${opts.title} ${why}.`, 0);
|
|
47
|
+
}
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let parsed = null;
|
|
50
|
+
if (text) {
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
parsed = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
if (res.status === 401 || res.status === 403) {
|
|
60
|
+
throw new PlatformError(`${opts.title} refused the token (${res.status}). Check that it is valid and can reach this project.`, res.status);
|
|
61
|
+
}
|
|
62
|
+
const msg = errorMessage(parsed) ?? res.statusText ?? "request failed";
|
|
63
|
+
throw new PlatformError(`${opts.title} returned ${res.status}: ${msg}`, res.status);
|
|
64
|
+
}
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PushAdapter } from "./types.js";
|
|
2
|
+
export declare const NETLIFY_API = "https://api.netlify.com";
|
|
3
|
+
export declare const isNetlifyReserved: (name: string) => boolean;
|
|
4
|
+
/** `.netlify/state.json`, written by `netlify link`. */
|
|
5
|
+
export declare function readNetlifyStateFile(cwd?: string): string | null;
|
|
6
|
+
export declare function createNetlifyAdapter(opts: {
|
|
7
|
+
token: string;
|
|
8
|
+
target: string;
|
|
9
|
+
site?: string;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}): PushAdapter;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isPublicEnvName, isReservedPushName } from "@getmyenv/shared";
|
|
4
|
+
import { PlatformError, platformRequest } from "./http.js";
|
|
5
|
+
export const NETLIFY_API = "https://api.netlify.com";
|
|
6
|
+
const MAX_VALUE_CHARS = 5000;
|
|
7
|
+
const LAMBDA_TOTAL_BYTES = 4 * 1024;
|
|
8
|
+
export const isNetlifyReserved = (name) => isReservedPushName("netlify", name);
|
|
9
|
+
/** `.netlify/state.json`, written by `netlify link`. */
|
|
10
|
+
export function readNetlifyStateFile(cwd = process.cwd()) {
|
|
11
|
+
const file = path.join(cwd, ".netlify", "state.json");
|
|
12
|
+
if (!fs.existsSync(file))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
16
|
+
return typeof raw.siteId === "string" && raw.siteId ? raw.siteId : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function createNetlifyAdapter(opts) {
|
|
23
|
+
const target = opts.target;
|
|
24
|
+
let siteId = "";
|
|
25
|
+
let accountId = "";
|
|
26
|
+
let vars = null;
|
|
27
|
+
const secretRefused = [];
|
|
28
|
+
const request = (method, p, body) => platformRequest({ title: "Netlify", base: NETLIFY_API, token: opts.token, method, path: p, body });
|
|
29
|
+
const envPath = (suffix = "") => `/api/v1/accounts/${encodeURIComponent(accountId)}/env${suffix}?site_id=${encodeURIComponent(siteId)}`;
|
|
30
|
+
const valuesOf = (v) => v.values ?? [];
|
|
31
|
+
const find = (name) => (vars ?? []).find((v) => v.key === name);
|
|
32
|
+
const ownValue = (v) => valuesOf(v).find((x) => x.context === target);
|
|
33
|
+
const hasAll = (v) => valuesOf(v).some((x) => x.context === "all");
|
|
34
|
+
async function loadVars() {
|
|
35
|
+
if (!vars)
|
|
36
|
+
vars = (await request("GET", envPath())) ?? [];
|
|
37
|
+
return vars;
|
|
38
|
+
}
|
|
39
|
+
async function create(name, value, secret) {
|
|
40
|
+
try {
|
|
41
|
+
await request("POST", envPath(), [{ key: name, values: [{ context: target, value }], is_secret: secret }]);
|
|
42
|
+
return secret;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (secret && err instanceof PlatformError && err.status >= 400 && err.status < 500 && /secret/i.test(err.message)) {
|
|
46
|
+
await request("POST", envPath(), [{ key: name, values: [{ context: target, value }], is_secret: false }]);
|
|
47
|
+
secretRefused.push(name);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function write(v, result) {
|
|
54
|
+
const existing = find(v.name);
|
|
55
|
+
const secret = v.type === "sensitive";
|
|
56
|
+
if (!existing) {
|
|
57
|
+
return (await create(v.name, v.value, secret)) ? "sensitive" : "readable";
|
|
58
|
+
}
|
|
59
|
+
if (hasAll(existing)) {
|
|
60
|
+
result.notWritten.push({
|
|
61
|
+
name: v.name,
|
|
62
|
+
reason: "it has one value for all deploy contexts in Netlify. Give it per-context values there, or remove it, then push again",
|
|
63
|
+
});
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const onlyOurs = valuesOf(existing).every((x) => x.context === target);
|
|
67
|
+
if (Boolean(existing.is_secret) !== secret && onlyOurs) {
|
|
68
|
+
// Netlify sets is_secret for the whole variable. It only serves this target, so replace it.
|
|
69
|
+
await request("DELETE", envPath(`/${encodeURIComponent(v.name)}`));
|
|
70
|
+
try {
|
|
71
|
+
return (await create(v.name, v.value, secret)) ? "sensitive" : "readable";
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
throw new Error(`${v.name} was removed from Netlify to change its type, then adding it again failed: ${err.message} Run push again.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
await request("PATCH", envPath(`/${encodeURIComponent(v.name)}`), { context: target, value: v.value });
|
|
78
|
+
const kept = existing.is_secret ? "sensitive" : "readable";
|
|
79
|
+
if (kept !== v.type) {
|
|
80
|
+
result.notes.push(`${v.name} keeps its Netlify type (${kept}). Netlify sets it for every deploy context of the variable.`);
|
|
81
|
+
}
|
|
82
|
+
return kept;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
id: "netlify",
|
|
86
|
+
title: "Netlify",
|
|
87
|
+
target,
|
|
88
|
+
isReserved: isNetlifyReserved,
|
|
89
|
+
typeFor: (name) => (isPublicEnvName(name) || target === "dev" ? "readable" : "sensitive"),
|
|
90
|
+
check(values) {
|
|
91
|
+
const skip = [];
|
|
92
|
+
const warnings = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const [name, value] of Object.entries(values)) {
|
|
95
|
+
if (value.length > MAX_VALUE_CHARS) {
|
|
96
|
+
skip.push({ name, reason: `longer than ${MAX_VALUE_CHARS} characters, the Netlify limit` });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
total += Buffer.byteLength(name) + Buffer.byteLength(value) + 1;
|
|
100
|
+
}
|
|
101
|
+
if (total > LAMBDA_TOTAL_BYTES) {
|
|
102
|
+
warnings.push(`These values add up to ${Math.ceil(total / 1024)} KB. Netlify Functions in Lambda compatibility mode get at most 4 KB of variables.`);
|
|
103
|
+
}
|
|
104
|
+
return { skip, warnings };
|
|
105
|
+
},
|
|
106
|
+
planNotes() {
|
|
107
|
+
if (target !== "dev")
|
|
108
|
+
return [];
|
|
109
|
+
return ["Netlify does not keep secret values for local development. Values for dev are readable."];
|
|
110
|
+
},
|
|
111
|
+
async destination() {
|
|
112
|
+
const id = opts.site ?? readNetlifyStateFile(opts.cwd);
|
|
113
|
+
if (!id) {
|
|
114
|
+
throw new Error("No Netlify site. Run `netlify link` in this folder, or pass --site <id>.");
|
|
115
|
+
}
|
|
116
|
+
const site = await request("GET", `/api/v1/sites/${encodeURIComponent(id)}`);
|
|
117
|
+
siteId = site.id;
|
|
118
|
+
accountId = site.account_id;
|
|
119
|
+
return {
|
|
120
|
+
provider: "netlify",
|
|
121
|
+
title: "Netlify",
|
|
122
|
+
externalId: site.id,
|
|
123
|
+
externalName: site.name,
|
|
124
|
+
ownerSlug: site.account_slug,
|
|
125
|
+
label: `${site.account_slug}/${site.name}`,
|
|
126
|
+
target,
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
async remoteNames() {
|
|
130
|
+
await loadVars();
|
|
131
|
+
return (vars ?? [])
|
|
132
|
+
.filter((v) => !isNetlifyReserved(v.key) && (ownValue(v) || hasAll(v)))
|
|
133
|
+
.map((v) => v.key);
|
|
134
|
+
},
|
|
135
|
+
async apply(upserts, prune) {
|
|
136
|
+
await loadVars();
|
|
137
|
+
const result = { written: [], pruned: [], notPruned: [], notWritten: [], notes: [] };
|
|
138
|
+
for (const v of upserts) {
|
|
139
|
+
try {
|
|
140
|
+
const type = await write(v, result);
|
|
141
|
+
if (type)
|
|
142
|
+
result.written.push({ name: v.name, type });
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
result.notWritten.push({ name: v.name, reason: err.message });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (secretRefused.length > 0) {
|
|
149
|
+
result.notes.push(`Netlify refused secret values for this account. Pushed as readable: ${secretRefused.join(", ")}.`);
|
|
150
|
+
}
|
|
151
|
+
for (const name of prune) {
|
|
152
|
+
const existing = find(name);
|
|
153
|
+
if (!existing)
|
|
154
|
+
continue;
|
|
155
|
+
if (hasAll(existing)) {
|
|
156
|
+
result.notPruned.push({ name, reason: "one value for all deploy contexts, cannot remove only " + target });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const own = ownValue(existing);
|
|
160
|
+
if (!own)
|
|
161
|
+
continue;
|
|
162
|
+
try {
|
|
163
|
+
if (valuesOf(existing).length === 1) {
|
|
164
|
+
await request("DELETE", envPath(`/${encodeURIComponent(name)}`));
|
|
165
|
+
}
|
|
166
|
+
else if (own.id) {
|
|
167
|
+
await request("DELETE", envPath(`/${encodeURIComponent(name)}/value/${encodeURIComponent(own.id)}`));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
result.notPruned.push({ name, reason: `Netlify did not return an id for its ${target} value` });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
result.pruned.push(name);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
result.notPruned.push({ name, reason: err.message });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PushProvider } from "@getmyenv/shared";
|
|
2
|
+
export type PushProviderId = PushProvider;
|
|
3
|
+
/** How the platform stores a value. Readable: anyone with project access sees it. Sensitive: write-only. */
|
|
4
|
+
export type VarType = "readable" | "sensitive";
|
|
5
|
+
export type Destination = {
|
|
6
|
+
provider: PushProviderId;
|
|
7
|
+
/** "Vercel" or "Netlify". */
|
|
8
|
+
title: string;
|
|
9
|
+
externalId: string;
|
|
10
|
+
/** Vercel project name or Netlify site name. Used in links. */
|
|
11
|
+
externalName: string;
|
|
12
|
+
/** Vercel team or user slug, Netlify account slug. */
|
|
13
|
+
ownerSlug: string;
|
|
14
|
+
/** What we print: "acme/web". */
|
|
15
|
+
label: string;
|
|
16
|
+
target: string;
|
|
17
|
+
};
|
|
18
|
+
export type PlannedVar = {
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
type: VarType;
|
|
22
|
+
change: "add" | "update";
|
|
23
|
+
};
|
|
24
|
+
export type Skipped = {
|
|
25
|
+
name: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
};
|
|
28
|
+
export type ApplyResult = {
|
|
29
|
+
written: Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
type: VarType;
|
|
32
|
+
}>;
|
|
33
|
+
pruned: string[];
|
|
34
|
+
notPruned: Skipped[];
|
|
35
|
+
notWritten: Skipped[];
|
|
36
|
+
notes: string[];
|
|
37
|
+
};
|
|
38
|
+
export interface PushAdapter {
|
|
39
|
+
readonly id: PushProviderId;
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly target: string;
|
|
42
|
+
/** Names the platform sets itself or refuses. Never written, never pruned. */
|
|
43
|
+
isReserved(name: string): boolean;
|
|
44
|
+
typeFor(name: string): VarType;
|
|
45
|
+
/** Local checks before any write. */
|
|
46
|
+
check(values: Record<string, string>): {
|
|
47
|
+
skip: Skipped[];
|
|
48
|
+
warnings: string[];
|
|
49
|
+
};
|
|
50
|
+
/** Extra lines printed with the plan. */
|
|
51
|
+
planNotes(plan: PlannedVar[]): string[];
|
|
52
|
+
destination(): Promise<Destination>;
|
|
53
|
+
/** Names that have a value for this target, without system or reserved names. */
|
|
54
|
+
remoteNames(): Promise<string[]>;
|
|
55
|
+
apply(upserts: PlannedVar[], prune: string[]): Promise<ApplyResult>;
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PushAdapter } from "./types.js";
|
|
2
|
+
export declare const VERCEL_API = "https://api.vercel.com";
|
|
3
|
+
export declare const isVercelReserved: (name: string) => boolean;
|
|
4
|
+
type ProjectRef = {
|
|
5
|
+
projectId: string;
|
|
6
|
+
teamId?: string;
|
|
7
|
+
};
|
|
8
|
+
/** `.vercel/project.json`, written by `vercel link`. */
|
|
9
|
+
export declare function readVercelProjectFile(cwd?: string): ProjectRef | null;
|
|
10
|
+
export declare function createVercelAdapter(opts: {
|
|
11
|
+
token: string;
|
|
12
|
+
target: string;
|
|
13
|
+
project?: string;
|
|
14
|
+
team?: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
}): PushAdapter;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isPublicEnvName, isReservedPushName } from "@getmyenv/shared";
|
|
4
|
+
import { PlatformError, platformRequest } from "./http.js";
|
|
5
|
+
export const VERCEL_API = "https://api.vercel.com";
|
|
6
|
+
const MAX_TOTAL_BYTES = 64 * 1024;
|
|
7
|
+
export const isVercelReserved = (name) => isReservedPushName("vercel", name);
|
|
8
|
+
/** `.vercel/project.json`, written by `vercel link`. */
|
|
9
|
+
export function readVercelProjectFile(cwd = process.cwd()) {
|
|
10
|
+
const file = path.join(cwd, ".vercel", "project.json");
|
|
11
|
+
if (!fs.existsSync(file))
|
|
12
|
+
return null;
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
if (typeof raw.projectId !== "string" || !raw.projectId)
|
|
16
|
+
return null;
|
|
17
|
+
const teamId = typeof raw.orgId === "string" && raw.orgId.startsWith("team_") ? raw.orgId : undefined;
|
|
18
|
+
return { projectId: raw.projectId, teamId };
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function targetsOf(env) {
|
|
25
|
+
if (Array.isArray(env.target))
|
|
26
|
+
return env.target;
|
|
27
|
+
return env.target ? [env.target] : [];
|
|
28
|
+
}
|
|
29
|
+
function vercelType(type) {
|
|
30
|
+
return type === "sensitive" ? "sensitive" : "encrypted";
|
|
31
|
+
}
|
|
32
|
+
export function createVercelAdapter(opts) {
|
|
33
|
+
const target = opts.target;
|
|
34
|
+
let projectId = "";
|
|
35
|
+
let teamId;
|
|
36
|
+
let envs = null;
|
|
37
|
+
const request = (method, p, body) => {
|
|
38
|
+
const sep = p.includes("?") ? "&" : "?";
|
|
39
|
+
const full = teamId ? `${p}${sep}teamId=${encodeURIComponent(teamId)}` : p;
|
|
40
|
+
return platformRequest({ title: "Vercel", base: VERCEL_API, token: opts.token, method, path: full, body });
|
|
41
|
+
};
|
|
42
|
+
/** Entries that set a value for the pushed target on every branch. Branch-only entries are not ours. */
|
|
43
|
+
const forTarget = (key) => (envs ?? []).filter((e) => !e.gitBranch && targetsOf(e).includes(target) && (key === undefined || e.key === key));
|
|
44
|
+
async function loadEnvs() {
|
|
45
|
+
if (!envs) {
|
|
46
|
+
const res = await request("GET", `/v9/projects/${encodeURIComponent(projectId)}/env`);
|
|
47
|
+
envs = res.envs ?? [];
|
|
48
|
+
}
|
|
49
|
+
return envs;
|
|
50
|
+
}
|
|
51
|
+
async function create(name, value, type) {
|
|
52
|
+
await request("POST", `/v10/projects/${encodeURIComponent(projectId)}/env`, {
|
|
53
|
+
key: name,
|
|
54
|
+
value,
|
|
55
|
+
type: vercelType(type),
|
|
56
|
+
target: [target],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function remove(id) {
|
|
60
|
+
await request("DELETE", `/v9/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(id)}`);
|
|
61
|
+
}
|
|
62
|
+
async function setTargets(id, targets) {
|
|
63
|
+
await request("PATCH", `/v9/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(id)}`, {
|
|
64
|
+
target: targets,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function write(v) {
|
|
68
|
+
const [entry] = forTarget(v.name);
|
|
69
|
+
const want = vercelType(v.type);
|
|
70
|
+
if (entry && targetsOf(entry).length === 1 && entry.type === want) {
|
|
71
|
+
await request("PATCH", `/v9/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(entry.id)}`, {
|
|
72
|
+
value: v.value,
|
|
73
|
+
});
|
|
74
|
+
return v.type;
|
|
75
|
+
}
|
|
76
|
+
if (entry && targetsOf(entry).length === 1) {
|
|
77
|
+
// Vercel does not change a variable's type in place. Remove and add it again, for this target only.
|
|
78
|
+
await remove(entry.id);
|
|
79
|
+
}
|
|
80
|
+
else if (entry) {
|
|
81
|
+
// The entry also serves other targets. They keep it. This target gets its own entry.
|
|
82
|
+
await setTargets(entry.id, targetsOf(entry).filter((t) => t !== target));
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await create(v.name, v.value, v.type);
|
|
86
|
+
return v.type;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (v.type === "sensitive" && target === "development" && err instanceof PlatformError && err.status === 400) {
|
|
90
|
+
await create(v.name, v.value, "readable");
|
|
91
|
+
return "readable";
|
|
92
|
+
}
|
|
93
|
+
if (entry) {
|
|
94
|
+
throw new Error(`${v.name} was taken off Vercel ${target} to write it again, then adding it failed: ${err.message} Run push again.`);
|
|
95
|
+
}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
id: "vercel",
|
|
101
|
+
title: "Vercel",
|
|
102
|
+
target,
|
|
103
|
+
isReserved: isVercelReserved,
|
|
104
|
+
typeFor: (name) => (isPublicEnvName(name) ? "readable" : "sensitive"),
|
|
105
|
+
check(values) {
|
|
106
|
+
const skip = [];
|
|
107
|
+
const warnings = [];
|
|
108
|
+
let total = 0;
|
|
109
|
+
for (const [name, value] of Object.entries(values)) {
|
|
110
|
+
const bytes = Buffer.byteLength(name) + Buffer.byteLength(value);
|
|
111
|
+
if (bytes > MAX_TOTAL_BYTES) {
|
|
112
|
+
skip.push({ name, reason: "larger than 64 KB, the Vercel limit" });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
total += bytes;
|
|
116
|
+
}
|
|
117
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
118
|
+
warnings.push(`These values add up to ${Math.ceil(total / 1024)} KB. Vercel allows 64 KB of variables per deployment.`);
|
|
119
|
+
}
|
|
120
|
+
return { skip, warnings };
|
|
121
|
+
},
|
|
122
|
+
planNotes(plan) {
|
|
123
|
+
if (target !== "development" || !plan.some((p) => p.type === "sensitive"))
|
|
124
|
+
return [];
|
|
125
|
+
return [
|
|
126
|
+
"If Vercel refuses Sensitive for Development, those variables are pushed as readable and listed after the push.",
|
|
127
|
+
];
|
|
128
|
+
},
|
|
129
|
+
async destination() {
|
|
130
|
+
const file = opts.project ? null : readVercelProjectFile(opts.cwd);
|
|
131
|
+
const ref = opts.project ? { projectId: opts.project, teamId: opts.team } : file;
|
|
132
|
+
if (!ref) {
|
|
133
|
+
throw new Error("No Vercel project. Run `vercel link` in this folder, or pass --project <id> (and --team <id> for a team project).");
|
|
134
|
+
}
|
|
135
|
+
projectId = ref.projectId;
|
|
136
|
+
teamId = opts.team ?? ref.teamId;
|
|
137
|
+
const project = await request("GET", `/v9/projects/${encodeURIComponent(projectId)}`);
|
|
138
|
+
projectId = project.id;
|
|
139
|
+
if (!teamId && project.accountId?.startsWith("team_"))
|
|
140
|
+
teamId = project.accountId;
|
|
141
|
+
let ownerSlug;
|
|
142
|
+
if (teamId) {
|
|
143
|
+
const team = await request("GET", `/v2/teams/${encodeURIComponent(teamId)}`);
|
|
144
|
+
ownerSlug = team.slug;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const me = await request("GET", "/v2/user");
|
|
148
|
+
ownerSlug = me.user.username;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
provider: "vercel",
|
|
152
|
+
title: "Vercel",
|
|
153
|
+
externalId: project.id,
|
|
154
|
+
externalName: project.name,
|
|
155
|
+
ownerSlug,
|
|
156
|
+
label: `${ownerSlug}/${project.name}`,
|
|
157
|
+
target,
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
async remoteNames() {
|
|
161
|
+
await loadEnvs();
|
|
162
|
+
return [
|
|
163
|
+
...new Set(forTarget()
|
|
164
|
+
.filter((e) => e.type !== "system" && !isVercelReserved(e.key))
|
|
165
|
+
.map((e) => e.key)),
|
|
166
|
+
];
|
|
167
|
+
},
|
|
168
|
+
async apply(upserts, prune) {
|
|
169
|
+
await loadEnvs();
|
|
170
|
+
const result = { written: [], pruned: [], notPruned: [], notWritten: [], notes: [] };
|
|
171
|
+
for (const v of upserts) {
|
|
172
|
+
try {
|
|
173
|
+
const type = await write(v);
|
|
174
|
+
result.written.push({ name: v.name, type });
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
result.notWritten.push({ name: v.name, reason: err.message });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const fellBack = result.written.filter((w) => w.type === "readable" && upserts.find((u) => u.name === w.name)?.type === "sensitive");
|
|
181
|
+
if (fellBack.length > 0) {
|
|
182
|
+
result.notes.push(`Vercel refused Sensitive for Development. Pushed as readable: ${fellBack.map((w) => w.name).join(", ")}.`);
|
|
183
|
+
}
|
|
184
|
+
for (const name of prune) {
|
|
185
|
+
for (const entry of forTarget(name)) {
|
|
186
|
+
const targets = targetsOf(entry);
|
|
187
|
+
try {
|
|
188
|
+
if (targets.length === 1)
|
|
189
|
+
await remove(entry.id);
|
|
190
|
+
else
|
|
191
|
+
await setTargets(entry.id, targets.filter((t) => t !== target));
|
|
192
|
+
if (!result.pruned.includes(name))
|
|
193
|
+
result.pruned.push(name);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
result.notPruned.push({ name, reason: err.message });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getmyenv",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "CLI for getmyenv, an encrypted store for environment variables and secrets. We store ciphertext only.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@getmyenv/crypto": "0.
|
|
37
|
-
"@getmyenv/shared": "0.
|
|
36
|
+
"@getmyenv/crypto": "0.6.0",
|
|
37
|
+
"@getmyenv/shared": "0.12.0",
|
|
38
38
|
"@napi-rs/keyring": "^2.1.0",
|
|
39
39
|
"commander": "^15.0.0",
|
|
40
40
|
"dotenv": "^16.4.7",
|