@iann29/synapse 1.8.5 → 1.8.7

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.
@@ -0,0 +1,173 @@
1
+ // `synapse deployment rotate-key <name> [--yes] [--confirm=<name>] [--write] [--json]`
2
+ //
3
+ // Calls POST /v1/deployments/{name}/reissue_admin_key. Re-mints the
4
+ // admin_key from the current INSTANCE_SECRET (no container restart).
5
+ //
6
+ // What this does NOT do:
7
+ // - It does NOT rotate INSTANCE_SECRET (so existing deploy keys keep
8
+ // working). To actually invalidate a leaked credential you'd need
9
+ // to recreate the deployment.
10
+ // - It does NOT auto-update the operator's .env.local. We surface a
11
+ // --write flag that opt-in does it (using the same writer as
12
+ // `synapse select`), but the default is "show the key and let the
13
+ // operator paste it".
14
+ //
15
+ // Safety:
16
+ // - prod deployments REQUIRE typed-confirm (--confirm=<name>) since
17
+ // rotating mid-flight breaks the iframed Convex Dashboard until the
18
+ // operator hits "Refresh credentials".
19
+ // - dev: `--yes` suffices.
20
+
21
+ const colors = require("../colors");
22
+ const { extractFlags } = require("./_resource");
23
+ const { confirm, typedConfirm } = require("../prompts");
24
+ const { SynapseAPIError } = require("../api");
25
+ const { writeProjectEnv } = require("../env-file");
26
+
27
+ module.exports = {
28
+ name: "deployment rotate-key",
29
+ summary: "Re-mint a deployment's admin key (cures stale credentials).",
30
+ usage:
31
+ "synapse deployment rotate-key <name> [--yes] [--confirm=<name>] [--write] [--json]",
32
+ description: `Calls POST /v1/deployments/{name}/reissue_admin_key. Used when the stored admin_key drifts out of sync with the running container (symptom: "deployment URL or admin key is invalid" inside the Convex Dashboard iframe).
33
+
34
+ The deployment's INSTANCE_SECRET is NOT rotated — existing deploy keys keep working. If you need to invalidate a leaked credential, delete + recreate the deployment.
35
+
36
+ Flags:
37
+ --yes Skip the y/N prompt (dev only).
38
+ --confirm=<name> Pre-supply the typed confirmation for prod.
39
+ --write Also rewrite .env.local in the current directory
40
+ IF this is the linked dev deployment.
41
+ --json Machine-readable output.
42
+
43
+ Examples:
44
+ synapse deployment rotate-key brave-dolphin-1060 --yes
45
+ synapse deployment rotate-key wise-otter-2200 --confirm=wise-otter-2200 --write`,
46
+
47
+ async run(args, ctx) {
48
+ const { flags, rest } = extractFlags(args, {
49
+ string: ["confirm"],
50
+ boolean: ["yes", "write", "json"],
51
+ });
52
+ const name = rest[0];
53
+ if (!name) {
54
+ throw new Error(
55
+ "Usage: synapse deployment rotate-key <name> [--yes|--confirm=<name>]",
56
+ );
57
+ }
58
+ if (rest.length > 1) {
59
+ throw new Error(`Unexpected positional: ${rest[1]}.`);
60
+ }
61
+ const yes = flags.yes === true || flags.yes === "true";
62
+ const write = flags.write === true || flags.write === "true";
63
+ const typed = typeof flags.confirm === "string" ? flags.confirm : null;
64
+
65
+ let dep;
66
+ try {
67
+ dep = await ctx.api.getDeployment(name);
68
+ } catch (err) {
69
+ if (err instanceof SynapseAPIError && err.status === 404) {
70
+ throw new Error(
71
+ `Deployment "${name}" not found. Run \`synapse list deployments\` to see your deployments.`,
72
+ );
73
+ }
74
+ throw err;
75
+ }
76
+ const type = dep.deploymentType || dep.type || "unknown";
77
+
78
+ // Adopted deployments can't be rotated — backend will 400, but we
79
+ // catch it earlier with a clearer message.
80
+ if (dep.adopted) {
81
+ throw new Error(
82
+ `Cannot rotate key for "${name}" — it's an adopted (external) deployment. Rotate the admin key on the source side and re-adopt.`,
83
+ );
84
+ }
85
+
86
+ if (type === "prod") {
87
+ if (!typed && ctx.out.json) {
88
+ throw new Error(
89
+ `Refusing to rotate prod key for "${name}" in --json mode without --confirm=${name}.`,
90
+ );
91
+ }
92
+ const ok = await typedConfirm("rotate-key", name, { typed });
93
+ if (!ok) {
94
+ ctx.out.info(`Aborted (confirmation didn't match "${name}").`);
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ } else {
99
+ if (!yes && !typed && !ctx.out.json) {
100
+ const confirmed = await confirm(
101
+ `Rotate admin key for ${type} deployment ${colors.bold(name)}? [y/N] `,
102
+ { defaultAnswer: false },
103
+ );
104
+ if (!confirmed) {
105
+ ctx.out.info("Aborted.");
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+ }
110
+ if (!yes && !typed && ctx.out.json) {
111
+ throw new Error(
112
+ `Refusing to rotate "${name}" in --json mode without --yes.`,
113
+ );
114
+ }
115
+ }
116
+
117
+ if (!ctx.out.json) {
118
+ ctx.out.info(`Rotating admin key for ${name}…`);
119
+ }
120
+ const res = await ctx.api.reissueAdminKey(name);
121
+
122
+ // Optional .env.local rewrite. Only fires when:
123
+ // - --write was passed
124
+ // - the rotated deployment is the linked dev deployment in CWD
125
+ // - we can fetch fresh credentials to wire in the new url+key
126
+ let envWritten = null;
127
+ if (write) {
128
+ const linked = ctx.projectConfig;
129
+ const matchedDev = linked?.deployments?.dev?.name === name;
130
+ if (!matchedDev) {
131
+ if (!ctx.out.json) {
132
+ ctx.out.warn(
133
+ "--write skipped: this is not the linked dev deployment in the current directory.",
134
+ );
135
+ }
136
+ } else {
137
+ const creds = await ctx.api.cliCredentials(name);
138
+ envWritten = writeProjectEnv(ctx.cwd, creds, {
139
+ team: { slug: linked.team?.slug, name: linked.team?.name },
140
+ project: { slug: linked.project?.slug, name: linked.project?.name },
141
+ target: "dev",
142
+ });
143
+ }
144
+ }
145
+
146
+ ctx.out.result(
147
+ {
148
+ rotated: true,
149
+ deploymentName: res.deploymentName || name,
150
+ prefix: res.prefix,
151
+ // Full new key. Same exposure profile as `synapse credentials`
152
+ // — operator already had this on disk before rotation; the
153
+ // surface is unchanged.
154
+ adminKey: res.adminKey,
155
+ envWritten,
156
+ },
157
+ (_d, { stdout }) => {
158
+ stdout.write(
159
+ `${colors.green("Rotated")} ${colors.bold(name)} ${colors.dim(`(prefix ${res.prefix})`)}\n`,
160
+ );
161
+ stdout.write(colors.dim("New admin key (paste into .env.local or pass --write):\n"));
162
+ stdout.write(res.adminKey + "\n");
163
+ if (envWritten) {
164
+ stdout.write(colors.green(`Updated ${envWritten}\n`));
165
+ } else if (write) {
166
+ stdout.write(
167
+ colors.dim("Hint: pass --write only when rotating the linked dev deployment.\n"),
168
+ );
169
+ }
170
+ },
171
+ );
172
+ },
173
+ };
@@ -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
+ };