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
package/dist/lib/run-env.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Pure pieces of `getmyenv run`. Merge, validate and explain live in
|
|
3
3
|
* @getmyenv/shared so the homepage demo prints the same text.
|
|
4
4
|
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
5
7
|
import { isValidVariableName } from "@getmyenv/shared";
|
|
6
8
|
export { explainLines, mergeEnv, runContextLabel, validationError } from "@getmyenv/shared";
|
|
7
9
|
/** Names from the server go into an environment or a file. Refuse any that are not variable names. */
|
|
@@ -12,23 +14,74 @@ export function assertVariableNames(names) {
|
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
16
|
}
|
|
17
|
+
/** Characters cmd.exe treats as special. Each one gets a ^ in front, outside or inside quotes. */
|
|
18
|
+
const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
|
|
19
|
+
/** Args made only of these go through as they are. */
|
|
20
|
+
const PLAIN_ARG = /^[\w\-.:/\\+@]+$/;
|
|
21
|
+
/**
|
|
22
|
+
* One argument for a Windows program: quoted by the MSVCRT rules (backslashes
|
|
23
|
+
* before a quote or the end are doubled, inner quotes become \"), then every
|
|
24
|
+
* cmd.exe metacharacter, quotes included, is escaped with ^ so cmd passes the
|
|
25
|
+
* text through untouched and never expands %VAR%. A .cmd or .bat file reads
|
|
26
|
+
* its arguments through cmd a second time, so those get a second ^.
|
|
27
|
+
*/
|
|
28
|
+
export function windowsArgument(arg, batch = false) {
|
|
29
|
+
if (arg !== "" && PLAIN_ARG.test(arg))
|
|
30
|
+
return arg;
|
|
31
|
+
let quoted = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
|
|
32
|
+
quoted = `"${quoted}"`.replace(CMD_META, "^$1");
|
|
33
|
+
return batch ? quoted.replace(CMD_META, "^$1") : quoted;
|
|
34
|
+
}
|
|
15
35
|
/**
|
|
16
36
|
* One cmd.exe command line from argv. Windows needs the shell to find `npm.cmd`
|
|
17
37
|
* and friends, and Node does not quote args passed alongside `shell: true`.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
38
|
+
* Arguments follow cross-spawn's rules (see `windowsArgument`). The command
|
|
39
|
+
* is quoted when it holds a space, so a path like C:\Program Files works.
|
|
40
|
+
*/
|
|
41
|
+
export function windowsCommandLine(args, opts = {}) {
|
|
42
|
+
const [command = "", ...rest] = args;
|
|
43
|
+
return [windowsCommandName(command), ...rest.map((a) => windowsArgument(a, opts.batch === true))].join(" ");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The program to start. cmd finds it by its own quote rules, and the program
|
|
47
|
+
* reads its own name from the same text, so a path with spaces is plainly
|
|
48
|
+
* quoted. A % is closed out of the quotes and escaped, so cmd does not expand it.
|
|
20
49
|
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
function windowsCommandName(command) {
|
|
51
|
+
if (PLAIN_ARG.test(command))
|
|
52
|
+
return command;
|
|
53
|
+
return `"${command.replace(/"/g, "").replace(/%/g, '"^%"')}"`;
|
|
54
|
+
}
|
|
55
|
+
function envValue(env, name) {
|
|
56
|
+
const key = Object.keys(env).find((k) => k.toUpperCase() === name);
|
|
57
|
+
return key ? env[key] : undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* True when cmd.exe would start `command` from a .cmd or .bat file (npm, npx,
|
|
61
|
+
* pnpm and most node_modules/.bin shims). Looks in the current folder, then
|
|
62
|
+
* PATH, with PATHEXT, the way cmd does.
|
|
63
|
+
*/
|
|
64
|
+
export function isWindowsBatch(command, env, cwd = process.cwd()) {
|
|
65
|
+
if (/\.(cmd|bat)$/i.test(command))
|
|
66
|
+
return true;
|
|
67
|
+
if (path.extname(command))
|
|
68
|
+
return false;
|
|
69
|
+
const exts = (envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
70
|
+
const hasDir = /[\\/]/.test(command);
|
|
71
|
+
const dirs = hasDir ? [""] : [cwd, ...(envValue(env, "PATH") ?? "").split(";").filter(Boolean)];
|
|
72
|
+
for (const dir of dirs) {
|
|
73
|
+
for (const ext of exts) {
|
|
74
|
+
const file = path.resolve(dir || cwd, `${command}${ext}`);
|
|
75
|
+
try {
|
|
76
|
+
if (fs.statSync(file).isFile())
|
|
77
|
+
return /^\.(cmd|bat)$/i.test(ext);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* not here */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
32
85
|
}
|
|
33
86
|
/** Exit code for a child that ended by signal: 128 + signal number, like a shell. */
|
|
34
87
|
export function signalExitCode(signal, numbers) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import pc from "
|
|
3
|
+
import { pcErr as pc } from "./colors.js";
|
|
4
4
|
import { mergeTemplateFile, templateFileSchema, TEMPLATE_FILE, TEMPLATE_FILE_MAX_KEYS, } from "@getmyenv/shared";
|
|
5
5
|
import { getLocalDir } from "./fs.js";
|
|
6
6
|
export function templateFilePath(cwd = process.cwd()) {
|
package/dist/lib/update-check.js
CHANGED
|
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import pc from "
|
|
9
|
+
import { pcErr as pc } from "./colors.js";
|
|
10
10
|
import { CLI_VERSION } from "./api.js";
|
|
11
11
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
12
|
const FETCH_TIMEOUT_MS = 2500;
|
package/dist/lib/upload.js
CHANGED
|
@@ -5,10 +5,10 @@ import { writeKey } from "./vault.js";
|
|
|
5
5
|
/** Fail fast before prompting or encrypting. Mirrors the server's `checkWrite`, which still enforces it. */
|
|
6
6
|
export function assertWritable(linked, ctx) {
|
|
7
7
|
if (linked.who.token.kind === "server") {
|
|
8
|
-
throw new Error(`Server tokens
|
|
8
|
+
throw new Error(`Server tokens cannot change values. ${FACTS.readOnlyWho}`);
|
|
9
9
|
}
|
|
10
10
|
if (ctx.protection === "locked") {
|
|
11
|
-
throw new Error(`${ctx.slug} is
|
|
11
|
+
throw new Error(`${ctx.slug} is Read-only. ${FACTS.readOnlyWho}`);
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
/**
|
package/dist/lib/vault.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import pc from "
|
|
1
|
+
import { pcErr as pc } from "./colors.js";
|
|
2
2
|
import { createUserKeys, generateContextKey, openSealedKey, sealContextKey, splitServerToken, unwrapPrivateKey, wipe, } from "@getmyenv/crypto";
|
|
3
3
|
import { VAULT_UNLOCK_DURATION } from "@getmyenv/shared";
|
|
4
4
|
import { ApiError } from "./api.js";
|
|
@@ -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,69 @@
|
|
|
1
|
+
import { withDeadline } 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
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
({ res, text } = await withDeadline(async (signal) => {
|
|
35
|
+
const res = await fetch(`${opts.base}${opts.path}`, {
|
|
36
|
+
method: opts.method,
|
|
37
|
+
headers: {
|
|
38
|
+
authorization: `Bearer ${opts.token}`,
|
|
39
|
+
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
40
|
+
},
|
|
41
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
42
|
+
redirect: "error",
|
|
43
|
+
signal,
|
|
44
|
+
});
|
|
45
|
+
return { res, text: await res.text() };
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const why = err instanceof Error && err.name === "TimeoutError" ? "timed out" : "is unreachable";
|
|
50
|
+
throw new PlatformError(`${opts.title} ${why}.`, 0);
|
|
51
|
+
}
|
|
52
|
+
let parsed = null;
|
|
53
|
+
if (text) {
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
parsed = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
if (res.status === 401 || res.status === 403) {
|
|
63
|
+
throw new PlatformError(`${opts.title} refused the token (${res.status}). Check that it is valid and can reach this project.`, res.status);
|
|
64
|
+
}
|
|
65
|
+
const msg = errorMessage(parsed) ?? res.statusText ?? "request failed";
|
|
66
|
+
throw new PlatformError(`${opts.title} returned ${res.status}: ${msg}`, res.status);
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
@@ -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,184 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isPublicEnvName, isReservedPushName } from "@getmyenv/shared";
|
|
4
|
+
import { pushApiBase } from "../lib/api-url.js";
|
|
5
|
+
import { PlatformError, platformRequest } from "./http.js";
|
|
6
|
+
export const NETLIFY_API = "https://api.netlify.com";
|
|
7
|
+
const MAX_VALUE_CHARS = 5000;
|
|
8
|
+
const LAMBDA_TOTAL_BYTES = 4 * 1024;
|
|
9
|
+
export const isNetlifyReserved = (name) => isReservedPushName("netlify", name);
|
|
10
|
+
/** `.netlify/state.json`, written by `netlify link`. */
|
|
11
|
+
export function readNetlifyStateFile(cwd = process.cwd()) {
|
|
12
|
+
const file = path.join(cwd, ".netlify", "state.json");
|
|
13
|
+
if (!fs.existsSync(file))
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
17
|
+
return typeof raw.siteId === "string" && raw.siteId ? raw.siteId : null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function createNetlifyAdapter(opts) {
|
|
24
|
+
const target = opts.target;
|
|
25
|
+
const base = pushApiBase(NETLIFY_API);
|
|
26
|
+
let siteId = "";
|
|
27
|
+
let accountId = "";
|
|
28
|
+
let vars = null;
|
|
29
|
+
const secretRefused = [];
|
|
30
|
+
const request = (method, p, body) => platformRequest({ title: "Netlify", base, token: opts.token, method, path: p, body });
|
|
31
|
+
const envPath = (suffix = "") => `/api/v1/accounts/${encodeURIComponent(accountId)}/env${suffix}?site_id=${encodeURIComponent(siteId)}`;
|
|
32
|
+
const valuesOf = (v) => v.values ?? [];
|
|
33
|
+
const find = (name) => (vars ?? []).find((v) => v.key === name);
|
|
34
|
+
const ownValue = (v) => valuesOf(v).find((x) => x.context === target);
|
|
35
|
+
const hasAll = (v) => valuesOf(v).some((x) => x.context === "all");
|
|
36
|
+
async function loadVars() {
|
|
37
|
+
if (!vars)
|
|
38
|
+
vars = (await request("GET", envPath())) ?? [];
|
|
39
|
+
return vars;
|
|
40
|
+
}
|
|
41
|
+
async function create(name, value, secret) {
|
|
42
|
+
try {
|
|
43
|
+
await request("POST", envPath(), [{ key: name, values: [{ context: target, value }], is_secret: secret }]);
|
|
44
|
+
return secret;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (secret && err instanceof PlatformError && err.status >= 400 && err.status < 500 && /secret/i.test(err.message)) {
|
|
48
|
+
await request("POST", envPath(), [{ key: name, values: [{ context: target, value }], is_secret: false }]);
|
|
49
|
+
secretRefused.push(name);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function write(v, result) {
|
|
56
|
+
const existing = find(v.name);
|
|
57
|
+
const secret = v.type === "sensitive";
|
|
58
|
+
if (!existing) {
|
|
59
|
+
return (await create(v.name, v.value, secret)) ? "sensitive" : "readable";
|
|
60
|
+
}
|
|
61
|
+
if (hasAll(existing)) {
|
|
62
|
+
result.notWritten.push({
|
|
63
|
+
name: v.name,
|
|
64
|
+
reason: "it has one value for all deploy contexts in Netlify. Give it per-context values there, or remove it, then push again",
|
|
65
|
+
});
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const onlyOurs = valuesOf(existing).every((x) => x.context === target);
|
|
69
|
+
if (Boolean(existing.is_secret) !== secret && onlyOurs) {
|
|
70
|
+
// Netlify sets is_secret for the whole variable. It only serves this target, so replace it.
|
|
71
|
+
await request("DELETE", envPath(`/${encodeURIComponent(v.name)}`));
|
|
72
|
+
try {
|
|
73
|
+
return (await create(v.name, v.value, secret)) ? "sensitive" : "readable";
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
throw new Error(`${v.name} was removed from Netlify to change its type, then adding it again failed: ${err.message} Run push again.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await request("PATCH", envPath(`/${encodeURIComponent(v.name)}`), { context: target, value: v.value });
|
|
80
|
+
const kept = existing.is_secret ? "sensitive" : "readable";
|
|
81
|
+
if (kept !== v.type) {
|
|
82
|
+
result.notes.push(`${v.name} keeps its Netlify type (${kept}). Netlify sets it for every deploy context of the variable.`);
|
|
83
|
+
}
|
|
84
|
+
return kept;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
id: "netlify",
|
|
88
|
+
title: "Netlify",
|
|
89
|
+
target,
|
|
90
|
+
isReserved: isNetlifyReserved,
|
|
91
|
+
typeFor: (name) => (isPublicEnvName(name) || target === "dev" ? "readable" : "sensitive"),
|
|
92
|
+
check(values) {
|
|
93
|
+
const skip = [];
|
|
94
|
+
const warnings = [];
|
|
95
|
+
let total = 0;
|
|
96
|
+
for (const [name, value] of Object.entries(values)) {
|
|
97
|
+
if (value.length > MAX_VALUE_CHARS) {
|
|
98
|
+
skip.push({ name, reason: `longer than ${MAX_VALUE_CHARS} characters, the Netlify limit` });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
total += Buffer.byteLength(name) + Buffer.byteLength(value) + 1;
|
|
102
|
+
}
|
|
103
|
+
if (total > LAMBDA_TOTAL_BYTES) {
|
|
104
|
+
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.`);
|
|
105
|
+
}
|
|
106
|
+
return { skip, warnings };
|
|
107
|
+
},
|
|
108
|
+
planNotes() {
|
|
109
|
+
if (target !== "dev")
|
|
110
|
+
return [];
|
|
111
|
+
return ["Netlify does not keep secret values for local development. Values for dev are readable."];
|
|
112
|
+
},
|
|
113
|
+
async destination() {
|
|
114
|
+
const id = opts.site ?? readNetlifyStateFile(opts.cwd);
|
|
115
|
+
if (!id) {
|
|
116
|
+
throw new Error("No Netlify site. Run `netlify link` in this folder, or pass --site <id>.");
|
|
117
|
+
}
|
|
118
|
+
const site = await request("GET", `/api/v1/sites/${encodeURIComponent(id)}`);
|
|
119
|
+
siteId = site.id;
|
|
120
|
+
accountId = site.account_id;
|
|
121
|
+
return {
|
|
122
|
+
provider: "netlify",
|
|
123
|
+
title: "Netlify",
|
|
124
|
+
externalId: site.id,
|
|
125
|
+
externalName: site.name,
|
|
126
|
+
ownerSlug: site.account_slug,
|
|
127
|
+
label: `${site.account_slug}/${site.name}`,
|
|
128
|
+
target,
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
async remoteNames() {
|
|
132
|
+
await loadVars();
|
|
133
|
+
return (vars ?? [])
|
|
134
|
+
.filter((v) => !isNetlifyReserved(v.key) && (ownValue(v) || hasAll(v)))
|
|
135
|
+
.map((v) => v.key);
|
|
136
|
+
},
|
|
137
|
+
async apply(upserts, prune) {
|
|
138
|
+
await loadVars();
|
|
139
|
+
const result = { written: [], pruned: [], notPruned: [], notWritten: [], notes: [] };
|
|
140
|
+
for (const v of upserts) {
|
|
141
|
+
try {
|
|
142
|
+
const type = await write(v, result);
|
|
143
|
+
if (type)
|
|
144
|
+
result.written.push({ name: v.name, type });
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
result.notWritten.push({ name: v.name, reason: err.message });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (secretRefused.length > 0) {
|
|
151
|
+
result.notes.push(`Netlify refused secret values for this account. Pushed as readable: ${secretRefused.join(", ")}.`);
|
|
152
|
+
}
|
|
153
|
+
for (const name of prune) {
|
|
154
|
+
const existing = find(name);
|
|
155
|
+
if (!existing)
|
|
156
|
+
continue;
|
|
157
|
+
if (hasAll(existing)) {
|
|
158
|
+
result.notPruned.push({ name, reason: "one value for all deploy contexts, cannot remove only " + target });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const own = ownValue(existing);
|
|
162
|
+
if (!own)
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
if (valuesOf(existing).length === 1) {
|
|
166
|
+
await request("DELETE", envPath(`/${encodeURIComponent(name)}`));
|
|
167
|
+
}
|
|
168
|
+
else if (own.id) {
|
|
169
|
+
await request("DELETE", envPath(`/${encodeURIComponent(name)}/value/${encodeURIComponent(own.id)}`));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
result.notPruned.push({ name, reason: `Netlify did not return an id for its ${target} value` });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
result.pruned.push(name);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
result.notPruned.push({ name, reason: err.message });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -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 {};
|