@iann29/synapse 1.8.6 → 1.8.8
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/bin/synapse.js +18 -1
- package/lib/api.js +54 -0
- package/lib/commands/_help.js +2 -1
- package/lib/commands/_resource.js +152 -0
- package/lib/commands/deployment-create.js +122 -0
- package/lib/commands/deployment-delete.js +117 -0
- package/lib/commands/deployment-rotate-key.js +173 -0
- package/lib/commands/deployment-status.js +143 -0
- package/lib/commands/env-list.js +105 -0
- package/lib/commands/env-pull.js +117 -0
- package/lib/commands/env-push.js +248 -0
- package/lib/commands/env-set.js +129 -0
- package/lib/commands/env-unset.js +77 -0
- package/lib/commands/https-doctor.js +73 -0
- package/lib/commands/https-migrate.js +168 -0
- package/lib/commands/https-remove.js +103 -0
- package/lib/commands/https-setup.js +263 -0
- package/lib/commands/https-status.js +154 -0
- package/lib/commands/list.js +186 -0
- package/lib/https/detect.js +603 -0
- package/lib/https/executor.js +102 -0
- package/lib/https/hosts.js +356 -0
- package/lib/https/mkcert.js +131 -0
- package/lib/https/nextjs.js +91 -0
- package/lib/https/planner.js +412 -0
- package/lib/prompts.js +50 -0
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// `synapse deployment status <name> [--watch[=interval]] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Single-deployment snapshot. Hits GET /v1/deployments/{name}/ +
|
|
4
|
+
// /backend_version (best-effort). `--watch` polls every 2s by default
|
|
5
|
+
// (configurable via --watch=<seconds>) until either:
|
|
6
|
+
// - the deployment reaches a terminal state (running / failed / deleted)
|
|
7
|
+
// - the operator hits Ctrl+C
|
|
8
|
+
// - --json mode is enabled (then we emit ONE snapshot, no polling)
|
|
9
|
+
|
|
10
|
+
const colors = require("../colors");
|
|
11
|
+
const { extractFlags } = require("./_resource");
|
|
12
|
+
const { SynapseAPIError } = require("../api");
|
|
13
|
+
|
|
14
|
+
const TERMINAL_STATES = new Set(["running", "failed", "errored", "deleted", "stopped"]);
|
|
15
|
+
|
|
16
|
+
async function fetchSnapshot(api, name) {
|
|
17
|
+
const dep = await api.getDeployment(name);
|
|
18
|
+
let backendVersion = null;
|
|
19
|
+
// Backend version is only meaningful once the container is running.
|
|
20
|
+
// We don't await this on every poll if we already saw a transient
|
|
21
|
+
// 503 — it's not load-bearing for the status check.
|
|
22
|
+
if (dep.status === "running") {
|
|
23
|
+
try {
|
|
24
|
+
const v = await api.getDeploymentBackendVersion(name);
|
|
25
|
+
backendVersion = v.version || null;
|
|
26
|
+
} catch {
|
|
27
|
+
backendVersion = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { dep, backendVersion };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderSnapshot(snap, { stdout }) {
|
|
34
|
+
const { dep, backendVersion } = snap;
|
|
35
|
+
const type = (dep.deploymentType || dep.type || "?").toUpperCase();
|
|
36
|
+
const status = dep.status || "?";
|
|
37
|
+
stdout.write(
|
|
38
|
+
`${colors.bold(dep.name)} ${colors.dim(type)} ${colors.statusBadge(status)}\n`,
|
|
39
|
+
);
|
|
40
|
+
if (dep.deploymentUrl || dep.url) {
|
|
41
|
+
stdout.write(` URL: ${dep.deploymentUrl || dep.url}\n`);
|
|
42
|
+
}
|
|
43
|
+
if (dep.haEnabled) {
|
|
44
|
+
stdout.write(
|
|
45
|
+
` HA: enabled${dep.replicaCount ? ` (${dep.replicaCount} replicas)` : ""}\n`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (backendVersion) {
|
|
49
|
+
stdout.write(colors.dim(` Convex backend: ${backendVersion}\n`));
|
|
50
|
+
}
|
|
51
|
+
if (dep.isDefault) {
|
|
52
|
+
stdout.write(colors.dim(` Default for the project.\n`));
|
|
53
|
+
}
|
|
54
|
+
if (dep.adopted) {
|
|
55
|
+
stdout.write(colors.dim(` Adopted (external — Synapse does not manage credentials).\n`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
name: "deployment status",
|
|
61
|
+
summary: "Show a single deployment's status, URL, and HA shape.",
|
|
62
|
+
usage:
|
|
63
|
+
"synapse deployment status <name> [--watch[=<seconds>]] [--json]",
|
|
64
|
+
description: `Fetches GET /v1/deployments/{name} plus a best-effort backend_version probe. --watch polls until the deployment reaches a terminal state (running/failed/deleted/stopped).
|
|
65
|
+
|
|
66
|
+
Flags:
|
|
67
|
+
--watch[=<seconds>] Poll until terminal. Default interval: 2s.
|
|
68
|
+
--json One-shot snapshot, no polling.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
synapse deployment status brave-dolphin-1060
|
|
72
|
+
synapse deployment status fresh-newt-1234 --watch # tail until ready
|
|
73
|
+
synapse deployment status brave-dolphin-1060 --json | jq .dep.status`,
|
|
74
|
+
|
|
75
|
+
// Re-exports for tests.
|
|
76
|
+
fetchSnapshot,
|
|
77
|
+
renderSnapshot,
|
|
78
|
+
TERMINAL_STATES,
|
|
79
|
+
|
|
80
|
+
async run(args, ctx) {
|
|
81
|
+
const { flags, rest } = extractFlags(args, {
|
|
82
|
+
// `--watch` alone → true (default interval); `--watch=5` → "5"
|
|
83
|
+
// (string parsed to number below). Both work because the
|
|
84
|
+
// boolean branch in extractFlags fires only for the no-`=` form.
|
|
85
|
+
boolean: ["watch", "json"],
|
|
86
|
+
});
|
|
87
|
+
const name = rest[0];
|
|
88
|
+
if (!name) {
|
|
89
|
+
throw new Error("Usage: synapse deployment status <name> [--watch]");
|
|
90
|
+
}
|
|
91
|
+
if (rest.length > 1) {
|
|
92
|
+
throw new Error(`Unexpected positional: ${rest[1]}.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const watch = flags.watch === true || flags.watch === "true"
|
|
96
|
+
? 2
|
|
97
|
+
: typeof flags.watch === "string"
|
|
98
|
+
? Math.max(1, Number.parseInt(flags.watch, 10) || 2)
|
|
99
|
+
: 0;
|
|
100
|
+
|
|
101
|
+
// --json + --watch combination doesn't make sense (we'd emit a
|
|
102
|
+
// never-ending stream of JSON objects). Fail closed.
|
|
103
|
+
if (ctx.out.json && watch > 0) {
|
|
104
|
+
throw new Error("--watch is incompatible with --json (would emit an unbounded stream).");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let snap;
|
|
108
|
+
try {
|
|
109
|
+
snap = await fetchSnapshot(ctx.api, name);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof SynapseAPIError && err.status === 404) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Deployment "${name}" not found. Run \`synapse list deployments\` to see your deployments.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (watch === 0) {
|
|
120
|
+
ctx.out.result(snap, renderSnapshot);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Watch mode (human only — we already barred --json above).
|
|
125
|
+
renderSnapshot(snap, { stdout: ctx.out.stdout });
|
|
126
|
+
while (!TERMINAL_STATES.has(snap.dep.status)) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, watch * 1000));
|
|
128
|
+
try {
|
|
129
|
+
snap = await fetchSnapshot(ctx.api, name);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err instanceof SynapseAPIError && err.status === 404) {
|
|
132
|
+
ctx.out.info(`Deployment "${name}" disappeared mid-watch.`);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
// Transient errors — log + continue. Operators see one stderr
|
|
136
|
+
// line per blip; persistent failures escalate via process exit.
|
|
137
|
+
ctx.out.warn(`probe failed (${err.message}); retrying`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
renderSnapshot(snap, { stdout: ctx.out.stdout });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `synapse env list [--for=dev|prod|preview] [--project=<id>] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Lists project-level (default) environment variables. These are
|
|
4
|
+
// injected into the Convex backend container at provision time;
|
|
5
|
+
// changing one only affects deployments that haven't been created yet
|
|
6
|
+
// UNLESS a separate `sync_env_to_deployments` job is triggered (see
|
|
7
|
+
// the dashboard's env panel).
|
|
8
|
+
//
|
|
9
|
+
// The `--for=<type>` filter narrows to vars whose deploymentTypes
|
|
10
|
+
// array includes the type. Omitted = show every var with its type set.
|
|
11
|
+
|
|
12
|
+
const colors = require("../colors");
|
|
13
|
+
const { extractFlags, resolveProject } = require("./_resource");
|
|
14
|
+
|
|
15
|
+
const VALID_FORS = new Set(["dev", "prod", "preview"]);
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
name: "env list",
|
|
19
|
+
summary: "List project-default environment variables.",
|
|
20
|
+
usage:
|
|
21
|
+
"synapse env list [--for=<dev|prod|preview>] [--project=<id>] [--mask] [--json]",
|
|
22
|
+
description: `Calls GET /v1/projects/{id}/list_default_environment_variables. Default behaviour shows the variable VALUES — pass --mask to redact them (useful when sharing a terminal recording).
|
|
23
|
+
|
|
24
|
+
Flags:
|
|
25
|
+
--for=<type> Filter to vars whose deploymentTypes include <type>.
|
|
26
|
+
--project=<id> Override the linked project.
|
|
27
|
+
--mask Redact values to "********" (length-preserving).
|
|
28
|
+
--json Machine-readable output.
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
synapse env list
|
|
32
|
+
synapse env list --for=prod --mask
|
|
33
|
+
synapse env list --json | jq '.configs[].name'`,
|
|
34
|
+
|
|
35
|
+
async run(args, ctx) {
|
|
36
|
+
const { flags, rest } = extractFlags(args, {
|
|
37
|
+
string: ["for", "project"],
|
|
38
|
+
boolean: ["mask", "json"],
|
|
39
|
+
});
|
|
40
|
+
if (rest.length > 0) {
|
|
41
|
+
throw new Error(`Unexpected positional: ${rest[0]}.`);
|
|
42
|
+
}
|
|
43
|
+
const filter = typeof flags.for === "string" ? flags.for.toLowerCase() : null;
|
|
44
|
+
if (filter && !VALID_FORS.has(filter)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Invalid --for: ${filter}. Must be one of: ${[...VALID_FORS].join(", ")}.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const mask = flags.mask === true || flags.mask === "true";
|
|
50
|
+
|
|
51
|
+
const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
|
|
52
|
+
const { projectId, source } = resolveProject(ctx, resolveArgs);
|
|
53
|
+
|
|
54
|
+
const resp = await ctx.api.listProjectEnvVars(projectId);
|
|
55
|
+
let configs = resp.configs || [];
|
|
56
|
+
if (filter) {
|
|
57
|
+
configs = configs.filter(
|
|
58
|
+
(c) => Array.isArray(c.deploymentTypes) && c.deploymentTypes.includes(filter),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
// Stable order for piping into diff tools.
|
|
62
|
+
configs.sort((a, b) => a.name.localeCompare(b.name));
|
|
63
|
+
|
|
64
|
+
ctx.out.result(
|
|
65
|
+
{
|
|
66
|
+
projectId,
|
|
67
|
+
projectSource: source,
|
|
68
|
+
count: configs.length,
|
|
69
|
+
filter,
|
|
70
|
+
masked: mask,
|
|
71
|
+
configs: mask
|
|
72
|
+
? configs.map((c) => ({ ...c, value: maskValue(c.value) }))
|
|
73
|
+
: configs,
|
|
74
|
+
},
|
|
75
|
+
(_d, { stdout }) => {
|
|
76
|
+
stdout.write(colors.dim(`Project: ${projectId}${filter ? ` · filter: ${filter}` : ""}\n`));
|
|
77
|
+
if (configs.length === 0) {
|
|
78
|
+
stdout.write(colors.dim("(no env vars)\n"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
ctx.out.table(
|
|
82
|
+
configs.map((c) => ({
|
|
83
|
+
name: colors.bold(c.name),
|
|
84
|
+
value: mask ? colors.dim(maskValue(c.value)) : c.value,
|
|
85
|
+
types: (c.deploymentTypes || []).join(",") || colors.dim("(all)"),
|
|
86
|
+
})),
|
|
87
|
+
[
|
|
88
|
+
{ key: "name", header: "NAME" },
|
|
89
|
+
{ key: "value", header: "VALUE" },
|
|
90
|
+
{ key: "types", header: "DEPLOYMENT_TYPES" },
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Length-preserving redaction. Operators sometimes screenshot the
|
|
99
|
+
// output to share — a fixed-length blob would leak the length.
|
|
100
|
+
function maskValue(v) {
|
|
101
|
+
if (v === undefined || v === null || v === "") return "";
|
|
102
|
+
return "*".repeat(String(v).length);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports.maskValue = maskValue;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// `synapse env pull [--out=<path>] [--for=<type>] [--project=<id>] [--json]`
|
|
2
|
+
//
|
|
3
|
+
// Reads project-default env vars + writes them as a .env-shaped file.
|
|
4
|
+
// Output defaults to stdout (so `synapse env pull >> .env.production`
|
|
5
|
+
// works). `--out=<path>` writes to disk with mode 0600.
|
|
6
|
+
//
|
|
7
|
+
// Values are quoted using the same `quoteEnvValue` helper that
|
|
8
|
+
// `.env.local` uses (handles backslashes, double quotes, newlines).
|
|
9
|
+
|
|
10
|
+
const fs = require("node:fs");
|
|
11
|
+
const colors = require("../colors");
|
|
12
|
+
const { extractFlags, resolveProject } = require("./_resource");
|
|
13
|
+
const { quoteEnvValue } = require("../env-file");
|
|
14
|
+
|
|
15
|
+
const VALID_FORS = new Set(["dev", "prod", "preview"]);
|
|
16
|
+
|
|
17
|
+
function formatDotenv(configs, { header = true } = {}) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
if (header) {
|
|
20
|
+
lines.push("# Synapse-managed project-default env vars");
|
|
21
|
+
lines.push("# Generated by `synapse env pull` — do not edit by hand;");
|
|
22
|
+
lines.push("# re-run the command after changes upstream.");
|
|
23
|
+
}
|
|
24
|
+
for (const c of configs) {
|
|
25
|
+
lines.push(`${c.name}=${quoteEnvValue(c.value)}`);
|
|
26
|
+
}
|
|
27
|
+
return lines.join("\n") + (lines.length > 0 ? "\n" : "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
name: "env pull",
|
|
32
|
+
summary: "Print or write project-default env vars in .env format.",
|
|
33
|
+
usage:
|
|
34
|
+
"synapse env pull [--out=<path>] [--for=<dev|prod|preview>] [--project=<id>] [--json]",
|
|
35
|
+
description: `Reads the project's default env vars and emits them as KEY="value" lines. Default: writes to stdout. With --out=<path>: writes to that file with mode 0600 (overwrites if exists).
|
|
36
|
+
|
|
37
|
+
Flags:
|
|
38
|
+
--out=<path> Write to a file with mode 0600 instead of stdout.
|
|
39
|
+
--for=<type> Filter to vars whose deploymentTypes include <type>.
|
|
40
|
+
--project=<id> Override the linked project.
|
|
41
|
+
--json Emits { count, body } instead of raw .env text.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
synapse env pull
|
|
45
|
+
synapse env pull --out=.env.synapse
|
|
46
|
+
synapse env pull --for=prod > .env.prod`,
|
|
47
|
+
|
|
48
|
+
// Re-exports for tests.
|
|
49
|
+
formatDotenv,
|
|
50
|
+
|
|
51
|
+
async run(args, ctx) {
|
|
52
|
+
const { flags, rest } = extractFlags(args, {
|
|
53
|
+
string: ["out", "for", "project"],
|
|
54
|
+
boolean: ["json"],
|
|
55
|
+
});
|
|
56
|
+
if (rest.length > 0) {
|
|
57
|
+
throw new Error(`Unexpected positional: ${rest[0]}.`);
|
|
58
|
+
}
|
|
59
|
+
const filter = typeof flags.for === "string" ? flags.for.toLowerCase() : null;
|
|
60
|
+
if (filter && !VALID_FORS.has(filter)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Invalid --for: ${filter}. Must be one of: ${[...VALID_FORS].join(", ")}.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const outPath = typeof flags.out === "string" ? flags.out : null;
|
|
66
|
+
|
|
67
|
+
const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
|
|
68
|
+
const { projectId, source } = resolveProject(ctx, resolveArgs);
|
|
69
|
+
|
|
70
|
+
const resp = await ctx.api.listProjectEnvVars(projectId);
|
|
71
|
+
let configs = resp.configs || [];
|
|
72
|
+
if (filter) {
|
|
73
|
+
configs = configs.filter(
|
|
74
|
+
(c) => Array.isArray(c.deploymentTypes) && c.deploymentTypes.includes(filter),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
configs.sort((a, b) => a.name.localeCompare(b.name));
|
|
78
|
+
|
|
79
|
+
const body = formatDotenv(configs);
|
|
80
|
+
|
|
81
|
+
if (outPath) {
|
|
82
|
+
fs.writeFileSync(outPath, body, { mode: 0o600 });
|
|
83
|
+
try {
|
|
84
|
+
fs.chmodSync(outPath, 0o600);
|
|
85
|
+
} catch {
|
|
86
|
+
// best-effort on systems without POSIX modes
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
ctx.out.result(
|
|
91
|
+
{
|
|
92
|
+
projectId,
|
|
93
|
+
projectSource: source,
|
|
94
|
+
filter,
|
|
95
|
+
count: configs.length,
|
|
96
|
+
outPath,
|
|
97
|
+
body,
|
|
98
|
+
},
|
|
99
|
+
(data, { stdout }) => {
|
|
100
|
+
if (outPath) {
|
|
101
|
+
ctx.out.info(
|
|
102
|
+
`Wrote ${data.count} env var${data.count === 1 ? "" : "s"} to ${outPath} (mode 0600).`,
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Plain .env text to stdout — no headers, no colours. The
|
|
107
|
+
// operator may be piping into another file.
|
|
108
|
+
stdout.write(body);
|
|
109
|
+
if (configs.length === 0) {
|
|
110
|
+
// The body is empty; tell the operator on stderr so they
|
|
111
|
+
// notice (stdout pipe wouldn't show anything).
|
|
112
|
+
ctx.out.info(colors.dim(`(no vars matched${filter ? ` --for=${filter}` : ""})`));
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// `synapse env push [--from=<path>] [--for=<types>] [--project=<id>]
|
|
2
|
+
// [--dry-run] [--yes] [--json]`
|
|
3
|
+
//
|
|
4
|
+
// Reads a .env-shaped file and applies it to the project's defaults
|
|
5
|
+
// via op=set on every entry. `--dry-run` prints the diff and exits
|
|
6
|
+
// without calling the backend (RECOMMENDED on first push).
|
|
7
|
+
//
|
|
8
|
+
// Safety:
|
|
9
|
+
// - Refuses to push CONVEX_SELF_HOSTED_URL / CONVEX_SELF_HOSTED_ADMIN_KEY
|
|
10
|
+
// (those belong in the operator's local .env.local, not in the
|
|
11
|
+
// project-default surface that gets injected into containers).
|
|
12
|
+
// - Refuses to push NEXT_PUBLIC_CONVEX_URL / SITE_URL for the same
|
|
13
|
+
// reason — they're managed by `synapse select`.
|
|
14
|
+
// - Without --yes the command shows a diff and prompts.
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const colors = require("../colors");
|
|
18
|
+
const { extractFlags, resolveProject } = require("./_resource");
|
|
19
|
+
const { parseEnvContent } = require("../env-file");
|
|
20
|
+
const { confirm } = require("../prompts");
|
|
21
|
+
|
|
22
|
+
// Names that MUST NEVER land in the project-default surface. A push
|
|
23
|
+
// that includes any of these is rejected before any backend call.
|
|
24
|
+
const DENY_LIST = new Set([
|
|
25
|
+
"CONVEX_SELF_HOSTED_URL",
|
|
26
|
+
"CONVEX_SELF_HOSTED_ADMIN_KEY",
|
|
27
|
+
"CONVEX_DEPLOYMENT",
|
|
28
|
+
"NEXT_PUBLIC_CONVEX_URL",
|
|
29
|
+
"NEXT_PUBLIC_CONVEX_SITE_URL",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
33
|
+
const VALID_FORS = new Set(["dev", "prod", "preview"]);
|
|
34
|
+
|
|
35
|
+
function diffEnv(currentConfigs, parsed) {
|
|
36
|
+
const current = new Map();
|
|
37
|
+
for (const c of currentConfigs) current.set(c.name, c.value);
|
|
38
|
+
const added = [];
|
|
39
|
+
const changed = [];
|
|
40
|
+
const unchanged = [];
|
|
41
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
42
|
+
if (!current.has(name)) {
|
|
43
|
+
added.push({ name, value });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (current.get(name) !== value) {
|
|
47
|
+
changed.push({ name, value, from: current.get(name) });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
unchanged.push({ name, value });
|
|
51
|
+
}
|
|
52
|
+
return { added, changed, unchanged };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
name: "env push",
|
|
57
|
+
summary: "Apply a .env-shaped file to project-default env vars.",
|
|
58
|
+
usage:
|
|
59
|
+
"synapse env push [--from=<path>] [--for=<types>] [--project=<id>] [--dry-run] [--yes] [--json]",
|
|
60
|
+
description: `Reads a .env file and sets each NAME=value as a project-default env var (op=set in batch). Best practice: run with --dry-run FIRST to see what would change.
|
|
61
|
+
|
|
62
|
+
The file format is the same one \`synapse env pull\` writes. Quoted values, comments, blank lines, and \`export\` prefixes are all tolerated.
|
|
63
|
+
|
|
64
|
+
The following names are REJECTED with an error (they live in .env.local, not in the project-default surface):
|
|
65
|
+
CONVEX_SELF_HOSTED_URL, CONVEX_SELF_HOSTED_ADMIN_KEY,
|
|
66
|
+
CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_CONVEX_SITE_URL
|
|
67
|
+
|
|
68
|
+
Flags:
|
|
69
|
+
--from=<path> File to read (default: .env in current dir).
|
|
70
|
+
--for=dev,prod Stamp the listed deploymentTypes on each var.
|
|
71
|
+
--project=<id> Override the linked project.
|
|
72
|
+
--dry-run Show the diff and exit; no backend call.
|
|
73
|
+
--yes Skip the y/N confirmation prompt.
|
|
74
|
+
--json Machine-readable output.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
synapse env push --from=.env.synapse --dry-run
|
|
78
|
+
synapse env push --from=.env.prod --for=prod --yes`,
|
|
79
|
+
|
|
80
|
+
// Re-exports for tests.
|
|
81
|
+
diffEnv,
|
|
82
|
+
DENY_LIST,
|
|
83
|
+
|
|
84
|
+
async run(args, ctx) {
|
|
85
|
+
const { flags, rest } = extractFlags(args, {
|
|
86
|
+
string: ["from", "for", "project"],
|
|
87
|
+
boolean: ["dry-run", "yes", "json"],
|
|
88
|
+
});
|
|
89
|
+
if (rest.length > 0) {
|
|
90
|
+
throw new Error(`Unexpected positional: ${rest[0]}.`);
|
|
91
|
+
}
|
|
92
|
+
const path = typeof flags.from === "string" ? flags.from : ".env";
|
|
93
|
+
if (!fs.existsSync(path)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`File not found: ${path}. Pass --from=<path> or create one with \`synapse env pull\`.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const dryRun = flags["dry-run"] === true || flags["dry-run"] === "true";
|
|
99
|
+
const yes = flags.yes === true || flags.yes === "true";
|
|
100
|
+
|
|
101
|
+
// Parse --for. Empty = backend default behaviour (no array sent).
|
|
102
|
+
let forList = null;
|
|
103
|
+
if (typeof flags.for === "string" && flags.for.trim() !== "") {
|
|
104
|
+
forList = [
|
|
105
|
+
...new Set(flags.for.split(",").map((s) => s.trim().toLowerCase())),
|
|
106
|
+
].filter(Boolean);
|
|
107
|
+
for (const f of forList) {
|
|
108
|
+
if (!VALID_FORS.has(f)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Invalid --for entry: ${f}. Must be one of: ${[...VALID_FORS].join(", ")}.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parsed = parseEnvContent(fs.readFileSync(path, "utf8"));
|
|
117
|
+
// Reject denied names BEFORE any backend call. Surface ALL of them
|
|
118
|
+
// so the operator can fix the file once instead of N times.
|
|
119
|
+
const violations = Object.keys(parsed).filter((n) => DENY_LIST.has(n));
|
|
120
|
+
if (violations.length > 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Refusing to push: ${violations.join(", ")} are reserved (live in .env.local, not in project-default env). Remove them from ${path} first.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
// Validate name shape.
|
|
126
|
+
for (const name of Object.keys(parsed)) {
|
|
127
|
+
if (!NAME_RE.test(name)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Invalid env name in ${path}: ${JSON.stringify(name)}. Must match [A-Z_][A-Z0-9_]*.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
|
|
135
|
+
const { projectId, source } = resolveProject(ctx, resolveArgs);
|
|
136
|
+
|
|
137
|
+
const current = await ctx.api.listProjectEnvVars(projectId);
|
|
138
|
+
const diff = diffEnv(current.configs || [], parsed);
|
|
139
|
+
|
|
140
|
+
// Dry-run: show diff and exit.
|
|
141
|
+
if (dryRun) {
|
|
142
|
+
ctx.out.result(
|
|
143
|
+
{
|
|
144
|
+
projectId,
|
|
145
|
+
projectSource: source,
|
|
146
|
+
file: path,
|
|
147
|
+
dryRun: true,
|
|
148
|
+
...diff,
|
|
149
|
+
},
|
|
150
|
+
(_d, { stdout }) => {
|
|
151
|
+
stdout.write(colors.dim(`Project: ${projectId} · source: ${path}\n`));
|
|
152
|
+
if (diff.added.length === 0 && diff.changed.length === 0) {
|
|
153
|
+
stdout.write(colors.dim("(nothing to do — file matches current state)\n"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
for (const a of diff.added) {
|
|
157
|
+
stdout.write(`${colors.green("+")} ${colors.bold(a.name)}\n`);
|
|
158
|
+
}
|
|
159
|
+
for (const c of diff.changed) {
|
|
160
|
+
stdout.write(`${colors.yellow("~")} ${colors.bold(c.name)}\n`);
|
|
161
|
+
}
|
|
162
|
+
if (diff.unchanged.length > 0) {
|
|
163
|
+
stdout.write(colors.dim(`(${diff.unchanged.length} unchanged)\n`));
|
|
164
|
+
}
|
|
165
|
+
stdout.write(colors.dim("\nDry-run — no changes applied. Re-run without --dry-run to push.\n"));
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Real push. Confirm in human mode unless --yes; refuse in
|
|
172
|
+
// --json mode unless --yes (CI must opt in explicitly).
|
|
173
|
+
const toApply = diff.added.length + diff.changed.length;
|
|
174
|
+
if (toApply === 0) {
|
|
175
|
+
ctx.out.result(
|
|
176
|
+
{
|
|
177
|
+
projectId,
|
|
178
|
+
projectSource: source,
|
|
179
|
+
file: path,
|
|
180
|
+
applied: 0,
|
|
181
|
+
...diff,
|
|
182
|
+
},
|
|
183
|
+
(_d, { stdout }) =>
|
|
184
|
+
stdout.write(colors.dim("Nothing to push — file matches current state.\n")),
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!yes && ctx.out.json) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Refusing to push ${toApply} change${toApply > 1 ? "s" : ""} in --json mode without --yes.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!yes && !ctx.out.json) {
|
|
194
|
+
ctx.out.info(`About to apply ${toApply} change${toApply > 1 ? "s" : ""}:`);
|
|
195
|
+
for (const a of diff.added) ctx.out.info(` + ${a.name}`);
|
|
196
|
+
for (const c of diff.changed) ctx.out.info(` ~ ${c.name}`);
|
|
197
|
+
const confirmed = await confirm(
|
|
198
|
+
`Push to project ${projectId}? [y/N] `,
|
|
199
|
+
{ defaultAnswer: false },
|
|
200
|
+
);
|
|
201
|
+
if (!confirmed) {
|
|
202
|
+
ctx.out.info("Aborted.");
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const changes = [];
|
|
209
|
+
for (const a of diff.added) {
|
|
210
|
+
changes.push({
|
|
211
|
+
op: "set",
|
|
212
|
+
name: a.name,
|
|
213
|
+
value: a.value,
|
|
214
|
+
...(forList ? { deploymentTypes: forList } : {}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (const c of diff.changed) {
|
|
218
|
+
changes.push({
|
|
219
|
+
op: "set",
|
|
220
|
+
name: c.name,
|
|
221
|
+
value: c.value,
|
|
222
|
+
...(forList ? { deploymentTypes: forList } : {}),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
await ctx.api.updateProjectEnvVars(projectId, changes);
|
|
226
|
+
|
|
227
|
+
ctx.out.result(
|
|
228
|
+
{
|
|
229
|
+
projectId,
|
|
230
|
+
projectSource: source,
|
|
231
|
+
file: path,
|
|
232
|
+
applied: changes.length,
|
|
233
|
+
...diff,
|
|
234
|
+
},
|
|
235
|
+
(_d, { stdout }) => {
|
|
236
|
+
for (const a of diff.added) {
|
|
237
|
+
stdout.write(`${colors.green("+")} ${colors.bold(a.name)}\n`);
|
|
238
|
+
}
|
|
239
|
+
for (const c of diff.changed) {
|
|
240
|
+
stdout.write(`${colors.yellow("~")} ${colors.bold(c.name)}\n`);
|
|
241
|
+
}
|
|
242
|
+
if (diff.unchanged.length > 0) {
|
|
243
|
+
stdout.write(colors.dim(`(${diff.unchanged.length} unchanged)\n`));
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
};
|