@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.
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
@@ -23,6 +23,24 @@ const {
23
23
  } = require("../config");
24
24
  const { readProjectConfig } = require("../project");
25
25
 
26
+ // v1.8.6 (A3): SessionExpiredError carries an operator-actionable
27
+ // message when the refresh token itself is rejected (>30d old or
28
+ // revoked at server side). bin/synapse.js prints err.message verbatim,
29
+ // so a clear "your session expired, run `synapse login <url>`" lands
30
+ // directly in front of the operator instead of a cryptic
31
+ // "Synapse API returned 401" message.
32
+ class SessionExpiredError extends Error {
33
+ constructor(baseUrl, cause) {
34
+ super(
35
+ `Your Synapse session expired. Run \`synapse login ${baseUrl}\` to sign in again.` +
36
+ (cause ? ` (refresh failed: ${cause})` : ""),
37
+ );
38
+ this.name = "SessionExpiredError";
39
+ this.baseUrl = baseUrl;
40
+ this.cause = cause;
41
+ }
42
+ }
43
+
26
44
  // Wraps an API client so any 401 transparently retries against
27
45
  // /v1/auth/refresh once. Mirrors what bin/synapse.js had before the
28
46
  // refactor; lives here so every command shares it.
@@ -43,10 +61,30 @@ function makeRefreshableApi(cfg) {
43
61
  ) {
44
62
  throw err;
45
63
  }
46
- const session = await new SynapseAPI({ baseUrl: cfg.baseUrl }).refresh(
47
- cfg.refreshToken,
48
- );
49
- if (!session.accessToken) throw err;
64
+ // v1.8.6 (A3): catch refresh failures and surface a clear
65
+ // re-login instruction instead of the raw upstream 401.
66
+ // Refresh tokens expire (30d default) or get revoked
67
+ // server-side; either way the operator needs to log in
68
+ // again and the bare "Synapse API returned 401" gave them
69
+ // no path forward.
70
+ let session;
71
+ try {
72
+ session = await new SynapseAPI({ baseUrl: cfg.baseUrl }).refresh(
73
+ cfg.refreshToken,
74
+ );
75
+ } catch (refreshErr) {
76
+ const detail =
77
+ refreshErr instanceof SynapseAPIError
78
+ ? `${refreshErr.code} ${refreshErr.status}`
79
+ : String(refreshErr.message || refreshErr);
80
+ throw new SessionExpiredError(cfg.baseUrl, detail);
81
+ }
82
+ if (!session.accessToken) {
83
+ throw new SessionExpiredError(
84
+ cfg.baseUrl,
85
+ "no access token in refresh response",
86
+ );
87
+ }
50
88
  cfg.accessToken = session.accessToken;
51
89
  cfg.refreshToken = session.refreshToken || cfg.refreshToken;
52
90
  cfg.tokenType = session.tokenType || cfg.tokenType || "Bearer";
@@ -84,10 +122,19 @@ function createContext({ out, cwd = process.cwd(), env = process.env } = {}) {
84
122
 
85
123
  // Throws "Not logged in" with a helpful message when no session
86
124
  // exists. Commands that REQUIRE auth call this.
125
+ //
126
+ // v1.8.6 (A1): copy now points operators who don't know the URL
127
+ // at their admin instead of leaving them stuck. This message
128
+ // cascades through 7+ commands (whoami / status / select /
129
+ // credentials / dev / deploy / convex) because the error
130
+ // propagates verbatim through bin/synapse.js's top-level
131
+ // try/catch.
87
132
  get cfg() {
88
133
  const c = cfgLoad();
89
134
  if (!c || !c.baseUrl || !c.accessToken) {
90
- throw new Error("Not logged in. Run `synapse login <url>` first.");
135
+ throw new Error(
136
+ "Not logged in. Run `synapse login <your-synapse-url>` (e.g. `synapse login https://synapsepanel.com`). If you don't know the URL, ask the admin who set up your Synapse host.",
137
+ );
91
138
  }
92
139
  _cfg = c;
93
140
  return c;
@@ -130,4 +177,9 @@ function createContext({ out, cwd = process.cwd(), env = process.env } = {}) {
130
177
  };
131
178
  }
132
179
 
133
- module.exports = { createContext, makeRefreshableApi, normalizeBaseUrl };
180
+ module.exports = {
181
+ createContext,
182
+ makeRefreshableApi,
183
+ normalizeBaseUrl,
184
+ SessionExpiredError,
185
+ };
@@ -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
+ };
@@ -117,10 +117,27 @@ async function runConvexCommand(args, ctx) {
117
117
  ctx.out.info(
118
118
  `Using Synapse ${resolved.target} deployment ${resolved.deploymentName}.`,
119
119
  );
120
+ // v1.8.6 (A5): the upstream Convex CLI emits "Can't safely modify
121
+ // .env.local for NEXT_PUBLIC_CONVEX_SITE_URL, please edit manually."
122
+ // because our value is a self-hosted URL that doesn't match its
123
+ // `.convex.site` pattern. The warning is benign (the file IS
124
+ // correct — we wrote it), but it's confusing without context.
125
+ // Pre-announce so the operator knows it's expected.
126
+ ctx.out.info(
127
+ "(npx convex may warn it can't modify NEXT_PUBLIC_CONVEX_SITE_URL — benign; Synapse owns those values.)",
128
+ );
120
129
  } else {
121
130
  resolved = await resolveConvexInvocation(args, { projectDir: ctx.cwd });
122
131
  }
123
132
  const code = await runConvex(resolved.args, { credentials: resolved.credentials });
133
+ // v1.8.6 (A5): when npx convex exits non-zero, surface a hint about
134
+ // where the failure came from — operators see a `[X]` from convex
135
+ // and assume Synapse broke. Point them at the right `--help`.
136
+ if (code !== 0) {
137
+ ctx.out.info(
138
+ `\n(npx convex exited ${code}. If this looks like an unknown-command typo, run \`synapse convex --help\` for the upstream Convex help.)`,
139
+ );
140
+ }
124
141
  process.exitCode = code;
125
142
  }
126
143
 
@@ -63,7 +63,28 @@ Formats:
63
63
  const { format, rest } = parseFormat(args);
64
64
  const deployment = rest[0];
65
65
  if (!deployment) {
66
- throw new Error("Usage: synapse credentials <deployment> [--format env|shell|json]");
66
+ // v1.8.6 (A4): the operator already linked a project — we know
67
+ // which deployment names exist. Surfacing them turns a dead-end
68
+ // Usage line into an actionable hint without an extra API call.
69
+ // The plain Usage line stays as the fallback for unlinked
70
+ // directories.
71
+ const linked = ctx.projectConfig;
72
+ let hint = "";
73
+ if (linked && linked.deployments) {
74
+ const names = [];
75
+ if (linked.deployments.dev?.name) {
76
+ names.push(`dev=${linked.deployments.dev.name}`);
77
+ }
78
+ if (linked.deployments.prod?.name) {
79
+ names.push(`prod=${linked.deployments.prod.name}`);
80
+ }
81
+ if (names.length > 0) {
82
+ hint = `\n\nThis project has: ${names.join(", ")}. Try \`synapse credentials ${linked.deployments.dev?.name || linked.deployments.prod?.name}\`. Run \`synapse status\` to see them all.`;
83
+ }
84
+ }
85
+ throw new Error(
86
+ `Usage: synapse credentials <deployment> [--format env|shell|json]${hint}`,
87
+ );
67
88
  }
68
89
  if (!FORMATS.has(format)) {
69
90
  throw new Error("format must be one of: env, shell, json");
@@ -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
+ };