@iann29/synapse 1.8.6 → 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.
package/bin/synapse.js CHANGED
@@ -30,7 +30,24 @@ async function main(argv) {
30
30
 
31
31
  const { cmd, rest } = resolve(REGISTRY, cleanArgv);
32
32
  if (!cmd) {
33
- process.stderr.write(`Unknown command: ${cleanArgv.join(" ")}\n\nRun \`synapse help\` for the full list.\n`);
33
+ // v1.8.7: if the operator typed `synapse <prefix>` where one or
34
+ // more two-word commands exist under that prefix, suggest them.
35
+ // Turns "Unknown command: deployment" → an actionable list of
36
+ // verbs the operator can pick from.
37
+ const prefix = cleanArgv[0] + " ";
38
+ const verbs = [];
39
+ for (const name of REGISTRY.keys()) {
40
+ if (name.startsWith(prefix)) verbs.push(name);
41
+ }
42
+ process.stderr.write(`Unknown command: ${cleanArgv.join(" ")}\n`);
43
+ if (verbs.length > 0) {
44
+ verbs.sort();
45
+ process.stderr.write(`\nDid you mean one of:\n`);
46
+ for (const v of verbs) process.stderr.write(` synapse ${v}\n`);
47
+ process.stderr.write(`\nRun \`synapse ${cleanArgv[0]} <verb> --help\` for usage.\n`);
48
+ } else {
49
+ process.stderr.write(`\nRun \`synapse help\` for the full list.\n`);
50
+ }
34
51
  process.exitCode = 1;
35
52
  return;
36
53
  }
package/lib/api.js CHANGED
@@ -131,6 +131,60 @@ class SynapseAPI {
131
131
  cliCredentials(deploymentName) {
132
132
  return this.request("GET", `/v1/deployments/${encodeURIComponent(deploymentName)}/cli_credentials`);
133
133
  }
134
+
135
+ // ---- v1.8.7 control-plane methods ------------------------------
136
+
137
+ // GET /v1/deployments/{name}/ — single-deployment snapshot for the
138
+ // status command (faster than filtering listDeployments client-side
139
+ // when the operator already knows the name).
140
+ getDeployment(name) {
141
+ return this.request("GET", `/v1/deployments/${encodeURIComponent(name)}/`);
142
+ }
143
+
144
+ // GET /v1/deployments/{name}/backend_version — version of the running
145
+ // Convex backend inside the container. Cheap and useful in `status`.
146
+ // Falls back to {} if the deployment isn't ready (status: provisioning).
147
+ getDeploymentBackendVersion(name) {
148
+ return this.request("GET", `/v1/deployments/${encodeURIComponent(name)}/backend_version`);
149
+ }
150
+
151
+ // POST /v1/projects/{id}/create_deployment — body: { type, ha?, reference?, isDefault? }.
152
+ // Backend generates the deployment name; we surface it in the response.
153
+ createDeployment(projectId, body) {
154
+ return this.request("POST", `/v1/projects/${encodeURIComponent(projectId)}/create_deployment`, body);
155
+ }
156
+
157
+ // POST /v1/deployments/{name}/delete — irreversible; container + volume
158
+ // gone. Returns 200 with no body on success. The caller is responsible
159
+ // for typed-confirmation in the UI layer.
160
+ deleteDeployment(name) {
161
+ return this.request("POST", `/v1/deployments/${encodeURIComponent(name)}/delete`, {});
162
+ }
163
+
164
+ // POST /v1/deployments/{name}/reissue_admin_key — re-mints the admin
165
+ // key from the current instance_secret WITHOUT touching the secret.
166
+ // Use when stored credentials drift from the live container.
167
+ // Response: { deploymentName, adminKey, prefix }.
168
+ reissueAdminKey(name) {
169
+ return this.request("POST", `/v1/deployments/${encodeURIComponent(name)}/reissue_admin_key`, {});
170
+ }
171
+
172
+ // GET /v1/projects/{id}/list_default_environment_variables — returns
173
+ // { configs: [{ name, value, deploymentTypes[] }] }. We hand the raw
174
+ // shape back; the command formats it.
175
+ listProjectEnvVars(projectId) {
176
+ return this.request("GET", `/v1/projects/${encodeURIComponent(projectId)}/list_default_environment_variables`);
177
+ }
178
+
179
+ // POST /v1/projects/{id}/update_default_environment_variables — body:
180
+ // { changes: [{ op: "set"|"delete", name, value?, deploymentTypes? }] }.
181
+ // Cloud-spec shape; the backend applies the batch in a transaction so
182
+ // partial-failure is impossible.
183
+ updateProjectEnvVars(projectId, changes) {
184
+ return this.request("POST", `/v1/projects/${encodeURIComponent(projectId)}/update_default_environment_variables`, {
185
+ changes,
186
+ });
187
+ }
134
188
  }
135
189
 
136
190
  // Known envelope keys, in priority order. We try these explicitly before
@@ -14,7 +14,7 @@ function renderRootHelp(registry, { stdout = process.stdout } = {}) {
14
14
  ["Session", ["login", "logout", "whoami"]],
15
15
  ["Project linking", ["select", "credentials"]],
16
16
  ["Day-to-day", ["dev", "deploy"]],
17
- ["Visibility", ["version", "status", "doctor", "open", "logs"]],
17
+ ["Visibility", ["version", "status", "doctor", "open", "list", "logs"]],
18
18
  ["Deployments", "deployment"],
19
19
  ["Projects", "project"],
20
20
  ["Teams", "team"],
@@ -0,0 +1,152 @@
1
+ // Shared helpers for v1.8.7 control-plane commands.
2
+ //
3
+ // Every command in the new "Deployments" + "Env vars" sections needs
4
+ // to resolve a target project — either from the linked `.synapse/
5
+ // project.json` (no flag) or from an explicit `--project=<id>` / team
6
+ // flag (CI / scripted runs). Centralising the logic here keeps the
7
+ // commands themselves slim and gives us one place to update if the
8
+ // resolution rules change.
9
+
10
+ // Extracts `--project=<id>` / `--project <id>` from argv. Returns the
11
+ // id (or null) plus the remaining args. We accept the equals form
12
+ // and the space-separated form — operators copy-paste from various
13
+ // shells.
14
+ function extractProjectFlag(args) {
15
+ const rest = [];
16
+ let projectId = null;
17
+ for (let i = 0; i < args.length; i += 1) {
18
+ const a = args[i];
19
+ if (a === "--project") {
20
+ projectId = args[i + 1];
21
+ i += 1;
22
+ continue;
23
+ }
24
+ if (a.startsWith("--project=")) {
25
+ projectId = a.slice("--project=".length);
26
+ continue;
27
+ }
28
+ rest.push(a);
29
+ }
30
+ return { projectId, rest };
31
+ }
32
+
33
+ // Same shape as extractProjectFlag for --team. team flag accepts either
34
+ // a slug or an id (backend's loadTeamForRequest accepts both).
35
+ function extractTeamFlag(args) {
36
+ const rest = [];
37
+ let teamRef = null;
38
+ for (let i = 0; i < args.length; i += 1) {
39
+ const a = args[i];
40
+ if (a === "--team") {
41
+ teamRef = args[i + 1];
42
+ i += 1;
43
+ continue;
44
+ }
45
+ if (a.startsWith("--team=")) {
46
+ teamRef = a.slice("--team=".length);
47
+ continue;
48
+ }
49
+ rest.push(a);
50
+ }
51
+ return { teamRef, rest };
52
+ }
53
+
54
+ // Generic flag extractor. Boolean flags (no value) and string flags
55
+ // (with `--name=value` form) must be DECLARED separately so the parser
56
+ // never has to guess whether `--default value-stays` means
57
+ // `default=true, positional=value-stays` (operator intent) or
58
+ // `default=value-stays` (greedy heuristic).
59
+ //
60
+ // Both spec shapes are accepted:
61
+ // extractFlags(args, ["foo", "bar"])
62
+ // → all flags string-or-true (legacy form; used by single-arg
63
+ // parsers that don't have boolean/string ambiguity)
64
+ // extractFlags(args, { string: ["type"], boolean: ["ha", "yes"] })
65
+ // → strict: boolean flags NEVER consume the next token; string
66
+ // flags require `--name=value` OR `--name <value>` (next-token).
67
+ //
68
+ // Unknown flags stay in `rest` (commands surface them with a Usage
69
+ // error).
70
+ function extractFlags(args, spec) {
71
+ let stringSet;
72
+ let booleanSet;
73
+ if (Array.isArray(spec)) {
74
+ // Legacy shape — keep the v1.8.x behaviour for `parseFlags`-style
75
+ // call sites that don't differentiate.
76
+ stringSet = new Set(spec);
77
+ booleanSet = new Set();
78
+ } else {
79
+ stringSet = new Set(spec.string || []);
80
+ booleanSet = new Set(spec.boolean || []);
81
+ }
82
+ const known = (n) => stringSet.has(n) || booleanSet.has(n);
83
+
84
+ const flags = {};
85
+ const rest = [];
86
+ for (let i = 0; i < args.length; i += 1) {
87
+ const a = args[i];
88
+ if (a.startsWith("--")) {
89
+ const eq = a.indexOf("=");
90
+ const name = eq >= 0 ? a.slice(2, eq) : a.slice(2);
91
+ if (known(name)) {
92
+ if (eq >= 0) {
93
+ // --name=value: always string (a boolean flag with `=` is
94
+ // unambiguous, e.g. `--yes=false`).
95
+ flags[name] = a.slice(eq + 1);
96
+ continue;
97
+ }
98
+ if (booleanSet.has(name) && !stringSet.has(name)) {
99
+ // Pure boolean. NEVER consumes the next token.
100
+ flags[name] = true;
101
+ continue;
102
+ }
103
+ // String (or legacy-spec) flag without `=`: consume next token
104
+ // if it isn't another flag. Boolean detection via the
105
+ // boolean-set above already short-circuited, so this branch
106
+ // is reached only for explicit string flags.
107
+ const next = args[i + 1];
108
+ if (next === undefined || next.startsWith("--")) {
109
+ flags[name] = true;
110
+ } else {
111
+ flags[name] = next;
112
+ i += 1;
113
+ }
114
+ continue;
115
+ }
116
+ }
117
+ rest.push(a);
118
+ }
119
+ return { flags, rest };
120
+ }
121
+
122
+ // Resolve the project the command should operate on. Result:
123
+ // { projectId, source: "flag" | "linked", projectConfig?, fallbackError? }
124
+ // Throws when neither a flag NOR a linked project is available (the
125
+ // error message tells the operator exactly what to do).
126
+ function resolveProject(ctx, args) {
127
+ const { projectId, rest } = extractProjectFlag(args);
128
+ if (projectId) {
129
+ return { projectId, source: "flag", rest };
130
+ }
131
+ const linked = ctx.projectConfig;
132
+ if (linked && linked.project && linked.project.id) {
133
+ return {
134
+ projectId: linked.project.id,
135
+ source: "linked",
136
+ projectConfig: linked,
137
+ rest,
138
+ };
139
+ }
140
+ const err = new Error(
141
+ "No linked project in this directory. Pass --project=<id> or run `synapse select` first.",
142
+ );
143
+ err.code = "no_project";
144
+ throw err;
145
+ }
146
+
147
+ module.exports = {
148
+ extractFlags,
149
+ extractProjectFlag,
150
+ extractTeamFlag,
151
+ resolveProject,
152
+ };
@@ -0,0 +1,122 @@
1
+ // `synapse deployment create [--type=dev|prod|preview|custom] [--ha]
2
+ // [--default] [--project=<id>] [--json]`
3
+ //
4
+ // Provisions a new Convex backend container under the linked (or
5
+ // --project=) project. The backend generates the deployment name
6
+ // (animal-adjective-NNNN) — we don't accept it as an arg, matching
7
+ // Convex Cloud's contract.
8
+ //
9
+ // Safety: prod creation prompts for `--yes` (or stdin "yes") because
10
+ // the container is real, gets a host port, and counts against quota.
11
+ // dev creates straight through.
12
+
13
+ const colors = require("../colors");
14
+ const { extractFlags, resolveProject } = require("./_resource");
15
+ const { confirm } = require("../prompts");
16
+
17
+ const VALID_TYPES = new Set(["dev", "prod", "preview", "custom"]);
18
+
19
+ module.exports = {
20
+ name: "deployment create",
21
+ summary: "Create a new Convex deployment under the linked project.",
22
+ usage:
23
+ "synapse deployment create [--type=dev|prod|preview|custom] [--ha] [--default] [--project=<id>] [--yes] [--json]",
24
+ description: `Provisions a real Convex backend container. The backend generates the deployment name (animal-adjective-NNNN); you receive it in the response.
25
+
26
+ Flags:
27
+ --type=<dev|prod|preview|custom> Deployment type. Default: dev.
28
+ --ha Provision HA (2 replicas + Postgres + S3).
29
+ Requires SYNAPSE_HA_ENABLED on the host.
30
+ --default Mark as the project's default deployment.
31
+ --project=<id> Operate on a non-linked project.
32
+ --yes Skip the prod-confirmation prompt.
33
+ --json Machine-readable output.
34
+
35
+ Examples:
36
+ synapse deployment create
37
+ synapse deployment create --type=prod --yes
38
+ synapse deployment create --type=dev --ha --default --project=<uuid>`,
39
+
40
+ async run(args, ctx) {
41
+ const { flags, rest } = extractFlags(args, {
42
+ string: ["type", "project"],
43
+ boolean: ["ha", "default", "yes"],
44
+ });
45
+ if (rest.length > 0) {
46
+ throw new Error(
47
+ `Unexpected positional: ${rest[0]}. The backend generates the deployment name; flags only.`,
48
+ );
49
+ }
50
+ // resolveProject reads from --project= via extractProjectFlag. We
51
+ // already consumed --project above; thread it back through args so
52
+ // resolveProject can see it.
53
+ const resolveArgs = flags.project ? [`--project=${flags.project}`] : [];
54
+ const { projectId, source } = resolveProject(ctx, resolveArgs);
55
+
56
+ const type = (flags.type || "dev").toLowerCase();
57
+ if (!VALID_TYPES.has(type)) {
58
+ throw new Error(
59
+ `Invalid --type: ${type}. Must be one of: ${[...VALID_TYPES].join(", ")}.`,
60
+ );
61
+ }
62
+ const ha = flags.ha === true || flags.ha === "true";
63
+ const isDefault = flags.default === true || flags.default === "true";
64
+ const yes = flags.yes === true || flags.yes === "true";
65
+
66
+ // Prod confirmation. Mirrors `synapse deploy`'s prompt — operator
67
+ // muscle-memory expects the same friction. Skipped in --json mode
68
+ // (CI scripts must pass --yes explicitly anyway).
69
+ if (type === "prod" && !yes && !ctx.out.json) {
70
+ const confirmed = await confirm(
71
+ `Create a NEW PROD deployment under ${source === "linked" ? "this directory's linked project" : projectId}? [y/N] `,
72
+ { defaultAnswer: false },
73
+ );
74
+ if (!confirmed) {
75
+ ctx.out.info("Aborted.");
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ }
80
+ if (type === "prod" && !yes && ctx.out.json) {
81
+ // Fail closed in JSON mode so a script can't accidentally
82
+ // provision prod without an explicit --yes.
83
+ throw new Error(
84
+ "Refusing to create a prod deployment in --json mode without --yes.",
85
+ );
86
+ }
87
+
88
+ if (!ctx.out.json) {
89
+ ctx.out.info(
90
+ `Creating ${type}${ha ? " (HA)" : ""}${isDefault ? " [default]" : ""} deployment in project ${projectId}…`,
91
+ );
92
+ }
93
+ const body = { type };
94
+ if (ha) body.ha = true;
95
+ if (isDefault) body.isDefault = true;
96
+ const created = await ctx.api.createDeployment(projectId, body);
97
+
98
+ ctx.out.result(
99
+ {
100
+ projectId,
101
+ projectSource: source,
102
+ deployment: created,
103
+ },
104
+ (_d, { stdout }) => {
105
+ stdout.write(
106
+ `${colors.green("Created")} ${colors.bold(created.name)} ${colors.dim(`(${created.deploymentType || type})`)}\n`,
107
+ );
108
+ if (created.deploymentUrl || created.url) {
109
+ stdout.write(colors.dim(`URL: ${created.deploymentUrl || created.url}\n`));
110
+ }
111
+ if (created.status) {
112
+ stdout.write(colors.dim(`Status: ${created.status}\n`));
113
+ }
114
+ stdout.write(
115
+ colors.dim(
116
+ `Tip: run \`synapse deployment status ${created.name} --watch\` to track readiness, or \`synapse select\` to link this directory to it.\n`,
117
+ ),
118
+ );
119
+ },
120
+ );
121
+ },
122
+ };
@@ -0,0 +1,117 @@
1
+ // `synapse deployment delete <name> [--yes] [--confirm=<name>] [--json]`
2
+ //
3
+ // Destroys the container + storage volume. Irreversible.
4
+ //
5
+ // Safety levels:
6
+ // - dev: single `--yes` confirms (or interactive y/N prompt)
7
+ // - prod: typed-confirmation REQUIRED — operator must re-type the
8
+ // deployment name. `--yes` is NOT enough for prod (mirrors the
9
+ // dashboard's v1.7.2 typed-confirm flow). Non-interactive callers
10
+ // pass `--confirm=<exact-name>` instead.
11
+ //
12
+ // The deployment type is fetched from the backend before the delete
13
+ // fires so we know whether to ask for typed-confirm. One extra GET is
14
+ // cheap and prevents the "I thought this was dev" footgun.
15
+
16
+ const colors = require("../colors");
17
+ const { extractFlags } = require("./_resource");
18
+ const { confirm, typedConfirm } = require("../prompts");
19
+ const { SynapseAPIError } = require("../api");
20
+
21
+ module.exports = {
22
+ name: "deployment delete",
23
+ summary: "Delete a deployment (container + volume — irreversible).",
24
+ usage:
25
+ "synapse deployment delete <name> [--yes] [--confirm=<name>] [--json]",
26
+ description: `Calls POST /v1/deployments/{name}/delete. The container is destroyed and the storage volume is wiped. Production deployments require typed confirmation — re-type the deployment name.
27
+
28
+ Flags:
29
+ --yes Skip the y/N prompt for dev deployments.
30
+ --confirm=<name> Pre-supply the typed confirmation (CI usage). For
31
+ prod deployments this is mandatory in --json mode.
32
+ --json Machine-readable output.
33
+
34
+ Examples:
35
+ synapse deployment delete brave-dolphin-1060
36
+ synapse deployment delete brave-dolphin-1060 --yes # dev only
37
+ synapse deployment delete wise-otter-2200 --confirm=wise-otter-2200`,
38
+
39
+ async run(args, ctx) {
40
+ const { flags, rest } = extractFlags(args, {
41
+ string: ["confirm"],
42
+ boolean: ["yes", "json"],
43
+ });
44
+ const name = rest[0];
45
+ if (!name) {
46
+ throw new Error(
47
+ "Usage: synapse deployment delete <name> [--yes|--confirm=<name>]",
48
+ );
49
+ }
50
+ if (rest.length > 1) {
51
+ throw new Error(`Unexpected positional: ${rest[1]}.`);
52
+ }
53
+ const yes = flags.yes === true || flags.yes === "true";
54
+ const typed = typeof flags.confirm === "string" ? flags.confirm : null;
55
+
56
+ // Look up the deployment to learn its type. 404 here is the most
57
+ // common operator typo case — surface a clear error before any
58
+ // destructive call.
59
+ let dep;
60
+ try {
61
+ dep = await ctx.api.getDeployment(name);
62
+ } catch (err) {
63
+ if (err instanceof SynapseAPIError && err.status === 404) {
64
+ throw new Error(
65
+ `Deployment "${name}" not found. Run \`synapse list deployments\` to see your deployments.`,
66
+ );
67
+ }
68
+ throw err;
69
+ }
70
+ const type = dep.deploymentType || dep.type || "unknown";
71
+
72
+ if (type === "prod") {
73
+ // Typed-confirmation MANDATORY. --yes alone does not bypass it.
74
+ if (!typed && ctx.out.json) {
75
+ throw new Error(
76
+ `Refusing to delete prod deployment "${name}" in --json mode without --confirm=${name}.`,
77
+ );
78
+ }
79
+ const ok = await typedConfirm("delete", name, { typed });
80
+ if (!ok) {
81
+ ctx.out.info(`Aborted (confirmation didn't match "${name}").`);
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ } else {
86
+ // Dev / preview / custom — y/N prompt unless --yes was passed.
87
+ if (!yes && !typed && !ctx.out.json) {
88
+ const confirmed = await confirm(
89
+ `Delete ${type} deployment ${colors.bold(name)}? [y/N] `,
90
+ { defaultAnswer: false },
91
+ );
92
+ if (!confirmed) {
93
+ ctx.out.info("Aborted.");
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ }
98
+ if (!yes && !typed && ctx.out.json) {
99
+ throw new Error(
100
+ `Refusing to delete "${name}" in --json mode without --yes.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ if (!ctx.out.json) {
106
+ ctx.out.info(`Deleting ${type} deployment ${name}…`);
107
+ }
108
+ await ctx.api.deleteDeployment(name);
109
+
110
+ ctx.out.result(
111
+ { deleted: true, name, type },
112
+ (_d, { stdout }) => {
113
+ stdout.write(`${colors.red("Deleted")} ${colors.bold(name)} ${colors.dim(`(${type})`)}\n`);
114
+ },
115
+ );
116
+ },
117
+ };
@@ -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
+ };