@iann29/synapse 1.6.17 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ // `synapse doctor` — the health-check battery. Walks ~12 checks
2
+ // across 4 categories (local-env, project, backend, deployments)
3
+ // in parallel where possible, then renders a panorama.
4
+ //
5
+ // Output is either a pretty terminal report (default) or a stable
6
+ // machine-readable JSON tree (--json). Exit codes: 0 ok, 1 warnings
7
+ // only, 2 at least one issue.
8
+
9
+ const path = require("node:path");
10
+ const os = require("node:os");
11
+ const { configPath } = require("../config");
12
+ const { generateReport, SCHEMA_VERSION } = require("../doctor/runner");
13
+ const { renderReport } = require("../doctor/renderer");
14
+
15
+ function parseFlags(args) {
16
+ let fix = false;
17
+ let yes = false;
18
+ let verbose = false;
19
+ const rest = [];
20
+ for (const a of args) {
21
+ if (a === "--fix") fix = true;
22
+ else if (a === "--yes" || a === "-y") yes = true;
23
+ else if (a === "--verbose" || a === "-v") verbose = true;
24
+ else rest.push(a);
25
+ }
26
+ return { fix, yes, verbose, rest };
27
+ }
28
+
29
+ module.exports = {
30
+ name: "doctor",
31
+ summary: "Run a health-check panorama on your local setup + Synapse backend + deployments.",
32
+ usage: "synapse doctor [--fix] [--yes] [--verbose] [--json]",
33
+ description: `Runs ~12 checks across local environment, project metadata, backend connectivity, and per-deployment health. Reports each check, the bottom-line totals, and exits with status:
34
+
35
+ 0 everything ok
36
+ 1 at least one warning (still functional)
37
+ 2 at least one issue (probably blocks your next deploy)
38
+
39
+ Flags:
40
+ --fix apply safe fixes automatically (chmod, gitignore appends, etc).
41
+ Checks marked 'prompt' require --yes too.
42
+ --yes upgrade 'prompt' fixes to auto.
43
+ --verbose show detailed data per check.
44
+ --json stable schema (current: v${SCHEMA_VERSION}); CI-friendly.
45
+
46
+ The --json output schema is documented in cli/schema/doctor-v${SCHEMA_VERSION}.json.`,
47
+
48
+ async run(args, ctx) {
49
+ const { fix, yes, verbose } = parseFlags(args);
50
+ // Build doctor's ctx — augment the dispatcher ctx with helpers
51
+ // checks expect (cfgPath for the home config, env for shell vars).
52
+ let api = null;
53
+ try {
54
+ // ctx.api throws if not logged in; we want checks to handle the
55
+ // "not logged in" case gracefully via cfgOrNull, so build the
56
+ // api lazily and only when we have a session.
57
+ if (ctx.cfgOrNull && ctx.cfgOrNull.accessToken) {
58
+ api = ctx.api;
59
+ }
60
+ } catch {
61
+ api = null;
62
+ }
63
+
64
+ const doctorCtx = {
65
+ cwd: ctx.cwd,
66
+ env: ctx.env,
67
+ cfg: ctx.cfgOrNull,
68
+ api,
69
+ projectConfig: ctx.projectConfig,
70
+ cfgPath: configPath(),
71
+ homedir: os.homedir(),
72
+ };
73
+
74
+ const report = await generateReport(doctorCtx, { fix, allowPrompt: yes });
75
+
76
+ ctx.out.result(report, (d, { stdout }) => {
77
+ renderReport(d, { stdout, verbose });
78
+ });
79
+
80
+ process.exitCode = report.exitCode;
81
+ },
82
+ };
@@ -0,0 +1,43 @@
1
+ // `synapse login <url>` — register the operator's session against a
2
+ // Synapse instance. The flow is intentionally minimal so it works
3
+ // over piped stdin (CI bootstrap): the prompts library detects a
4
+ // non-TTY and accepts `email\npassword\n` instead of rendering the
5
+ // hidden-password prompt.
6
+
7
+ const { askCredentials } = require("../prompts");
8
+ const { SynapseAPI } = require("../api");
9
+ const { normalizeBaseUrl, writeConfig } = require("../config");
10
+
11
+ module.exports = {
12
+ name: "login",
13
+ summary: "Authenticate against a Synapse instance and save the session locally.",
14
+ usage: "synapse login <url>",
15
+ description: `Prompts for email + password (or reads them piped on stdin) and writes the resulting session to ~/.synapse/config.json (mode 0600).
16
+
17
+ The saved session carries both the access token (1h TTL) and a refresh token (30d TTL); subsequent commands silent-refresh on 401 transparently — operators don't see /login prompts mid-task.`,
18
+
19
+ async run(args, ctx) {
20
+ const url = args[0];
21
+ if (!url) {
22
+ throw new Error("Usage: synapse login <url>");
23
+ }
24
+ const baseUrl = normalizeBaseUrl(url);
25
+ const { email, password } = await askCredentials();
26
+ const api = new SynapseAPI({ baseUrl });
27
+ const session = await api.login(email, password);
28
+ if (!session.accessToken) {
29
+ throw new Error("Synapse login response did not include accessToken");
30
+ }
31
+ const file = writeConfig({
32
+ baseUrl,
33
+ accessToken: session.accessToken,
34
+ refreshToken: session.refreshToken || null,
35
+ tokenType: session.tokenType || "Bearer",
36
+ user: session.user || null,
37
+ });
38
+ ctx.out.result(
39
+ { baseUrl, user: session.user || null, configPath: file },
40
+ () => ctx.out.info(`Saved Synapse session to ${file}`),
41
+ );
42
+ },
43
+ };
@@ -0,0 +1,20 @@
1
+ // `synapse logout` — clear the saved session from ~/.synapse/config.json.
2
+ // No backend call (the JWT stays technically valid until exp; the
3
+ // point is to delete the refresh token from this machine so future
4
+ // 401s can't silent-refresh into a fresh session).
5
+
6
+ const { clearConfig } = require("../config");
7
+
8
+ module.exports = {
9
+ name: "logout",
10
+ summary: "Clear the saved Synapse session from this machine.",
11
+ usage: "synapse logout",
12
+ description: `Deletes ~/.synapse/config.json. Does not contact the backend — tokens stay technically valid until their natural exp, but the refresh token is gone so silent-refresh can no longer pick up where you left off.`,
13
+
14
+ async run(_args, ctx) {
15
+ const removed = clearConfig();
16
+ ctx.out.result({ removed }, () =>
17
+ ctx.out.info(removed ? "Logged out of Synapse." : "No Synapse session was saved."),
18
+ );
19
+ },
20
+ };
@@ -0,0 +1,96 @@
1
+ // `synapse open [target]` — launches a URL in the operator's default
2
+ // browser. Cross-platform via the right `open` / `xdg-open` / `start`
3
+ // command per OS.
4
+ //
5
+ // Targets:
6
+ // (default) the linked project's page in the Synapse dashboard
7
+ // dashboard same as default
8
+ // docs docs.convex.dev (the upstream Convex docs)
9
+ // deployment <n> /embed/<name> — the dashboard with Convex Dashboard iframe
10
+ // url just the synapse base URL (for "open the dashboard root")
11
+ //
12
+ // No backend call; everything is built client-side from the saved cfg
13
+ // + projectConfig.
14
+
15
+ const { spawn } = require("node:child_process");
16
+
17
+ function buildUrl(target, restArgs, { cfg, projectConfig }) {
18
+ const base = cfg?.baseUrl ?? "";
19
+ switch (target) {
20
+ case undefined:
21
+ case "dashboard": {
22
+ if (projectConfig?.team?.slug && projectConfig?.project?.id) {
23
+ return `${base}/teams/${encodeURIComponent(projectConfig.team.slug)}/${encodeURIComponent(projectConfig.project.id)}`;
24
+ }
25
+ // Fall back to /teams (operator picks the project visually).
26
+ return `${base}/teams`;
27
+ }
28
+ case "docs":
29
+ return "https://docs.convex.dev";
30
+ case "deployment": {
31
+ const name = restArgs[0];
32
+ if (!name) {
33
+ throw new Error("Usage: synapse open deployment <name>");
34
+ }
35
+ return `${base}/embed/${encodeURIComponent(name)}`;
36
+ }
37
+ case "url":
38
+ return base || "https://docs.convex.dev";
39
+ default:
40
+ throw new Error(
41
+ `Unknown target: ${target}. Try: dashboard | docs | deployment <name> | url`,
42
+ );
43
+ }
44
+ }
45
+
46
+ function launcher(platform = process.platform) {
47
+ if (platform === "darwin") return { cmd: "open", shell: false };
48
+ if (platform === "win32") return { cmd: "start", shell: true };
49
+ // Linux + others
50
+ return { cmd: "xdg-open", shell: false };
51
+ }
52
+
53
+ module.exports = {
54
+ name: "open",
55
+ summary: "Open a Synapse-related URL in your default browser.",
56
+ usage: "synapse open [dashboard|docs|deployment <name>|url] [--json]",
57
+ description: `Launches a browser. With no argument, opens the linked project's page in the dashboard.
58
+
59
+ Targets:
60
+ dashboard project page on the Synapse dashboard (default)
61
+ docs https://docs.convex.dev
62
+ deployment <n> /embed/<n> — Convex Dashboard iframe shell
63
+ url the saved Synapse base URL`,
64
+
65
+ // Exports for tests.
66
+ buildUrl,
67
+ launcher,
68
+
69
+ async run(args, ctx) {
70
+ const target = args[0];
71
+ const url = buildUrl(target, args.slice(1), {
72
+ cfg: ctx.cfgOrNull,
73
+ projectConfig: ctx.projectConfig,
74
+ });
75
+
76
+ if (ctx.out.json) {
77
+ ctx.out.result({ url, target: target ?? "dashboard" }, () => {});
78
+ return;
79
+ }
80
+
81
+ const { cmd, shell } = launcher();
82
+ ctx.out.info(`Opening ${url}`);
83
+ try {
84
+ const child = spawn(cmd, [url], { stdio: "ignore", detached: true, shell });
85
+ child.unref();
86
+ } catch (err) {
87
+ // If the launcher isn't on PATH (rare — xdg-open is part of
88
+ // xdg-utils, ships on every desktop Linux), the operator can
89
+ // still see the URL we tried.
90
+ ctx.out.error(`Could not launch browser: ${err.message}`, {
91
+ hint: `Open this URL manually: ${url}`,
92
+ });
93
+ process.exitCode = 1;
94
+ }
95
+ },
96
+ };
@@ -0,0 +1,239 @@
1
+ // `synapse select` — link the current directory to a Synapse project +
2
+ // pick a dev/prod deployment, then write the local artifacts
3
+ // (.synapse/project.json + .env.local).
4
+ //
5
+ // The picker walks team → project → dev → prod as a state machine so
6
+ // the operator can type `b` at any level to step back one. Auto-
7
+ // selects each level when only one option exists. DEBUG_SYNAPSE=1
8
+ // dumps the underlying lists at every step — useful when a real
9
+ // deployment is mysteriously missing from the menu.
10
+
11
+ const colors = require("../colors");
12
+ const { writeProjectEnv } = require("../env-file");
13
+ const {
14
+ buildProjectConfig,
15
+ writeProjectConfig,
16
+ } = require("../project");
17
+ const { BACK, choose } = require("../prompts");
18
+
19
+ function labelName(item) {
20
+ const name = item.name || item.slug || item.id;
21
+ const slug = item.slug && item.slug !== name ? ` (${item.slug})` : "";
22
+ return `${name}${slug}`;
23
+ }
24
+
25
+ function teamRef(team) {
26
+ return team.slug || team.id;
27
+ }
28
+
29
+ function deploymentType(deployment) {
30
+ return deployment.deploymentType || deployment.type || "";
31
+ }
32
+
33
+ function deploymentLabel(deployment) {
34
+ const bits = [colors.bold(deployment.name)];
35
+ const type = deploymentType(deployment);
36
+ if (type) bits.push(colors.dim(type));
37
+ if (deployment.status) bits.push(colors.statusBadge(deployment.status));
38
+ return bits.filter(Boolean).join(" - ");
39
+ }
40
+
41
+ function sortDeploymentsForChoice(deployments) {
42
+ return [...deployments].sort((a, b) => {
43
+ if (!!a.isDefault !== !!b.isDefault) {
44
+ return a.isDefault ? -1 : 1;
45
+ }
46
+ return String(b.createTime || b.createdAt || "").localeCompare(
47
+ String(a.createTime || a.createdAt || ""),
48
+ );
49
+ });
50
+ }
51
+
52
+ function debugLog(msg) {
53
+ if (process.env.DEBUG_SYNAPSE) {
54
+ process.stderr.write(`[DEBUG] ${msg}\n`);
55
+ }
56
+ }
57
+
58
+ async function chooseDeploymentForType(type, deployments, chooseOpts = {}) {
59
+ const matches = sortDeploymentsForChoice(
60
+ deployments.filter(
61
+ (d) => deploymentType(d) === type && d.status !== "deleted",
62
+ ),
63
+ );
64
+ debugLog(
65
+ `chooseDeploymentForType(${type}): matched ${matches.length} of ${deployments.length} ` +
66
+ `(types: ${deployments.map((d) => deploymentType(d) || "?").join(",")})`,
67
+ );
68
+ if (matches.length === 0) return null;
69
+ return await choose(
70
+ `${type} deployments`,
71
+ matches.map((d) => ({ label: deploymentLabel(d), value: d })),
72
+ { singularLabel: `${type} deployment`, ...chooseOpts },
73
+ );
74
+ }
75
+
76
+ module.exports = {
77
+ name: "select",
78
+ summary: "Link this directory to a Synapse project and pick its dev/prod deployments.",
79
+ usage: "synapse select",
80
+ description: `Walks an interactive picker (team → project → dev → prod) and writes:
81
+ .synapse/project.json refs only — no secrets, safe to commit
82
+ .env.local CONVEX_SELF_HOSTED_URL + CONVEX_SELF_HOSTED_ADMIN_KEY
83
+
84
+ Auto-selects levels where only one option exists. Type 'b' at any
85
+ prompt to step back. Set DEBUG_SYNAPSE=1 to print the raw lists
86
+ returned at every step (useful when an expected deployment doesn't
87
+ show up in the menu).`,
88
+
89
+ // Exported for legacy test imports.
90
+ chooseDeploymentForType,
91
+ deploymentLabel,
92
+ deploymentType,
93
+ labelName,
94
+ sortDeploymentsForChoice,
95
+
96
+ async run(_args, ctx) {
97
+ const { api } = ctx;
98
+ const cfg = ctx.cfg;
99
+
100
+ const cache = {
101
+ teamsList: null,
102
+ projectsByTeamKey: new Map(),
103
+ deploymentsByProjectId: new Map(),
104
+ };
105
+ async function fetchTeams() {
106
+ if (!cache.teamsList) {
107
+ cache.teamsList = await api.teams();
108
+ debugLog(`teams loaded: ${cache.teamsList.length}`);
109
+ }
110
+ return cache.teamsList;
111
+ }
112
+ async function fetchProjects(team) {
113
+ const key = team.id || team.slug || team.name;
114
+ if (!cache.projectsByTeamKey.has(key)) {
115
+ const projects = await api.projects(teamRef(team));
116
+ cache.projectsByTeamKey.set(key, projects);
117
+ debugLog(`projects for team ${key}: ${projects.length}`);
118
+ }
119
+ return cache.projectsByTeamKey.get(key);
120
+ }
121
+ async function fetchDeployments(project) {
122
+ if (!cache.deploymentsByProjectId.has(project.id)) {
123
+ const deployments = await api.deployments(project.id);
124
+ cache.deploymentsByProjectId.set(project.id, deployments);
125
+ debugLog(`deployments for project ${project.id}: ${deployments.length}`);
126
+ }
127
+ return cache.deploymentsByProjectId.get(project.id);
128
+ }
129
+
130
+ let team = null;
131
+ let project = null;
132
+ let dev = null;
133
+ let prod = null;
134
+ let step = "team";
135
+ while (step !== "done") {
136
+ if (step === "team") {
137
+ const teams = await fetchTeams();
138
+ const picked = await choose(
139
+ "teams",
140
+ teams.map((t) => ({ label: labelName(t), value: t })),
141
+ { singularLabel: "team", allowBack: false },
142
+ );
143
+ team = picked;
144
+ step = "project";
145
+ } else if (step === "project") {
146
+ const projects = await fetchProjects(team);
147
+ const picked = await choose(
148
+ "projects",
149
+ projects.map((p) => ({ label: labelName(p), value: p })),
150
+ { singularLabel: "project", allowBack: true },
151
+ );
152
+ if (picked === BACK) {
153
+ step = "team";
154
+ continue;
155
+ }
156
+ project = picked;
157
+ step = "dev";
158
+ } else if (step === "dev") {
159
+ const deployments = await fetchDeployments(project);
160
+ const picked = await chooseDeploymentForType("dev", deployments, {
161
+ allowBack: true,
162
+ });
163
+ if (picked === BACK) {
164
+ step = "project";
165
+ continue;
166
+ }
167
+ if (picked === null) {
168
+ throw new Error(
169
+ "No dev deployments available in this project. Create one first in the dashboard.",
170
+ );
171
+ }
172
+ dev = picked;
173
+ step = "prod";
174
+ } else if (step === "prod") {
175
+ const deployments = await fetchDeployments(project);
176
+ const picked = await chooseDeploymentForType("prod", deployments, {
177
+ allowBack: true,
178
+ });
179
+ if (picked === BACK) {
180
+ step = "dev";
181
+ continue;
182
+ }
183
+ prod = picked; // null is valid (no prod yet)
184
+ step = "done";
185
+ }
186
+ }
187
+
188
+ const projectPath = writeProjectConfig(
189
+ ctx.cwd,
190
+ buildProjectConfig({
191
+ synapseUrl: cfg.baseUrl,
192
+ team,
193
+ project,
194
+ deployments: { dev, prod },
195
+ }),
196
+ );
197
+ const creds = await api.cliCredentials(dev.name);
198
+ const envPath = writeProjectEnv(ctx.cwd, creds);
199
+
200
+ ctx.out.result(
201
+ {
202
+ synapseUrl: cfg.baseUrl,
203
+ team: { id: team.id, slug: team.slug, name: team.name },
204
+ project: { id: project.id, slug: project.slug, name: project.name },
205
+ deployments: {
206
+ dev: { name: dev.name, type: deploymentType(dev), status: dev.status },
207
+ prod: prod
208
+ ? { name: prod.name, type: deploymentType(prod), status: prod.status }
209
+ : null,
210
+ },
211
+ files: { projectConfig: projectPath, envLocal: envPath },
212
+ },
213
+ () => {
214
+ ctx.out.info(`\nLinked ${labelName(project)} to ${projectPath}.`);
215
+ ctx.out.info(
216
+ `Selected dev deployment ${colors.bold(dev.name)}. Updated ${envPath}.`,
217
+ );
218
+ if (prod) {
219
+ ctx.out.info(`Selected prod deployment ${colors.bold(prod.name)}.`);
220
+ } else {
221
+ ctx.out.warn(
222
+ "no prod deployment found. `synapse deploy` (and `synapse convex deploy`) will fail with a clear error until you create a prod deployment and run `synapse select` again.",
223
+ );
224
+ }
225
+ if (process.env.CONVEX_DEPLOYMENT) {
226
+ ctx.out.warn(
227
+ "shell CONVEX_DEPLOYMENT is set. Use `synapse dev` / `synapse deploy` / `synapse convex ...` or unset CONVEX_DEPLOYMENT before running `npx convex` directly.",
228
+ );
229
+ }
230
+ ctx.out.info(
231
+ `\nNext step: run ${colors.bold("synapse dev")} (or ${colors.bold("npx convex dev")}) once in this directory to push your schema and watch for changes.`,
232
+ );
233
+ },
234
+ );
235
+ // Force ctx.projectConfig to re-read on next access (commands chained
236
+ // after select within the same process should see the new file).
237
+ ctx.refreshProjectConfig();
238
+ },
239
+ };
@@ -0,0 +1,173 @@
1
+ // `synapse status` — quick summary of every deployment in the linked
2
+ // project. Status icon + type badge + URL + URL form chip. Mirrors
3
+ // what the dashboard's project page shows, in 1 terminal screen.
4
+ //
5
+ // Works without `synapse select` IF --project=<id> is passed. Otherwise
6
+ // reads .synapse/project.json from cwd.
7
+
8
+ const colors = require("../colors");
9
+
10
+ // Same heuristic as dashboard/app/embed/[name]/page.tsx:isBrowserReachableURL.
11
+ // We classify the URL form so the operator knows at a glance which
12
+ // deployments are reachable from the browser vs which fall back to
13
+ // the host:port form that needs SYNAPSE_BASE_DOMAIN or a custom domain.
14
+ function urlForm(rawUrl, publicUrl) {
15
+ if (!rawUrl) return "unknown";
16
+ let u;
17
+ try {
18
+ u = new URL(rawUrl);
19
+ } catch {
20
+ return "unknown";
21
+ }
22
+ if (u.pathname.startsWith("/d/")) return "path";
23
+ const STANDARD = new Set(["", "443", "80", "6791"]);
24
+ if (
25
+ !STANDARD.has(u.port) &&
26
+ u.hostname !== "localhost" &&
27
+ u.hostname !== "127.0.0.1"
28
+ ) {
29
+ return "host";
30
+ }
31
+ if (!publicUrl) return "custom";
32
+ try {
33
+ const pu = new URL(publicUrl);
34
+ if (u.hostname === pu.hostname) return "custom";
35
+ if (u.hostname.endsWith("." + pu.hostname)) return "wildcard";
36
+ return "custom";
37
+ } catch {
38
+ return "custom";
39
+ }
40
+ }
41
+
42
+ function parseFlags(args) {
43
+ let projectId = null;
44
+ const rest = [];
45
+ for (let i = 0; i < args.length; i += 1) {
46
+ const arg = args[i];
47
+ if (arg === "--project") {
48
+ projectId = args[i + 1];
49
+ i += 1;
50
+ } else if (arg.startsWith("--project=")) {
51
+ projectId = arg.slice("--project=".length);
52
+ } else {
53
+ rest.push(arg);
54
+ }
55
+ }
56
+ return { projectId, rest };
57
+ }
58
+
59
+ module.exports = {
60
+ name: "status",
61
+ summary: "List deployments in the linked project with status + URL form.",
62
+ usage: "synapse status [--project=<id>] [--json]",
63
+ description: `Lists every deployment in the linked project (or the project given by --project=<id>) along with its status, URL, and URL form ('custom' / 'wildcard' / 'path' / 'host').
64
+
65
+ The URL form tells you whether the deployment is reachable from a browser:
66
+ custom bound to a custom domain (api.client.com) — reachable
67
+ wildcard uses SYNAPSE_BASE_DOMAIN subdomain — reachable
68
+ path /d/<name>/* via the proxy — browser-reachable but CLI-broken
69
+ (the Convex CLI strips paths from base URLs)
70
+ host host:port form — NOT reachable from a normal browser
71
+ (Caddy doesn't TLS-front dynamic ports)
72
+
73
+ Run \`synapse doctor\` for a deeper health check.`,
74
+
75
+ // Re-exports for tests.
76
+ urlForm,
77
+ parseFlags,
78
+
79
+ async run(args, ctx) {
80
+ const { projectId: explicitProjectId } = parseFlags(args);
81
+ let projectId = explicitProjectId;
82
+ let projectLabel = null;
83
+ if (!projectId) {
84
+ const pc = ctx.requireProject();
85
+ projectId = pc.project.id;
86
+ projectLabel = pc.project.name || pc.project.slug;
87
+ }
88
+
89
+ const deployments = await ctx.api.deployments(projectId);
90
+ const baseUrl = ctx.cfg.baseUrl;
91
+
92
+ const rows = deployments.map((d) => {
93
+ const type = d.deploymentType || d.type || "";
94
+ const url = d.deploymentUrl || d.url || "";
95
+ return {
96
+ name: d.name,
97
+ type,
98
+ status: d.status,
99
+ url,
100
+ urlForm: urlForm(url, baseUrl),
101
+ isDefault: !!d.isDefault,
102
+ adopted: !!d.adopted,
103
+ haEnabled: !!d.haEnabled,
104
+ replicaCount: d.replicaCount,
105
+ };
106
+ });
107
+
108
+ ctx.out.result(
109
+ { projectId, project: projectLabel, deployments: rows },
110
+ (data, { stdout }) => {
111
+ if (data.project) {
112
+ stdout.write(colors.bold(`Project: ${data.project}\n`));
113
+ }
114
+ if (rows.length === 0) {
115
+ stdout.write(colors.dim("(no deployments)\n"));
116
+ return;
117
+ }
118
+ const tag = (form) => {
119
+ switch (form) {
120
+ case "custom":
121
+ return colors.green("custom");
122
+ case "wildcard":
123
+ return colors.green("wildcard");
124
+ case "path":
125
+ return colors.dim("path");
126
+ case "host":
127
+ return colors.red("no-domain");
128
+ default:
129
+ return colors.dim("?");
130
+ }
131
+ };
132
+ ctx.out.table(
133
+ rows.map((r) => ({
134
+ name: colors.bold(r.name),
135
+ type: typeBadge(r.type),
136
+ status: colors.statusBadge(r.status || ""),
137
+ urlForm: tag(r.urlForm),
138
+ url: r.url,
139
+ })),
140
+ [
141
+ { key: "name", header: "NAME" },
142
+ { key: "type", header: "TYPE" },
143
+ { key: "status", header: "STATUS" },
144
+ { key: "urlForm", header: "FORM" },
145
+ { key: "url", header: "URL" },
146
+ ],
147
+ );
148
+ const broken = rows.filter((r) => r.urlForm === "host").length;
149
+ if (broken > 0) {
150
+ stdout.write("\n");
151
+ ctx.out.warn(
152
+ `${broken} deployment${broken > 1 ? "s" : ""} not browser-reachable — set SYNAPSE_BASE_DOMAIN on the server OR add a custom domain per deployment. Run \`synapse doctor\` for the full diagnosis.`,
153
+ );
154
+ }
155
+ },
156
+ );
157
+ },
158
+ };
159
+
160
+ // Tiny tone helper that mirrors the dashboard's envTone() so colors
161
+ // stay consistent across UI surfaces.
162
+ function typeBadge(t) {
163
+ switch (t) {
164
+ case "prod":
165
+ return colors.yellow(t.toUpperCase());
166
+ case "dev":
167
+ return colors.cyan ? colors.cyan(t.toUpperCase()) : t.toUpperCase();
168
+ case "preview":
169
+ return colors.dim(t.toUpperCase());
170
+ default:
171
+ return t ? colors.dim(t) : "";
172
+ }
173
+ }