@iann29/synapse 1.7.0 → 1.8.1

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,619 @@
1
+ // Doctor's check catalog. Each check is a pure-ish function that returns
2
+ // a CheckResult (or throws — the runner catches and converts to issue).
3
+ //
4
+ // The runner imports `ALL_CHECKS` (in execution order, respecting tier
5
+ // dependencies) and iterates. New checks should be added here with a
6
+ // stable id; --json consumers depend on the id surface staying stable
7
+ // across schemaVersion bumps.
8
+
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+ const os = require("node:os");
12
+ const { SynapseAPI, SynapseAPIError } = require("../api");
13
+ const { readProjectEnv } = require("../env-file");
14
+ const { writeProjectConfig } = require("../project");
15
+
16
+ const REQUIRED_NODE = "18.17.0";
17
+
18
+ // Compare semver-ish strings. Returns -1 / 0 / 1.
19
+ function cmpVer(a, b) {
20
+ const ap = a.replace(/^v/, "").split(".").map(Number);
21
+ const bp = b.replace(/^v/, "").split(".").map(Number);
22
+ for (let i = 0; i < 3; i += 1) {
23
+ const av = ap[i] || 0;
24
+ const bv = bp[i] || 0;
25
+ if (av !== bv) return av < bv ? -1 : 1;
26
+ }
27
+ return 0;
28
+ }
29
+
30
+ // Wrap a check function so a thrown error doesn't crash the runner.
31
+ function safeRun(fn) {
32
+ return async (ctx) => {
33
+ const t0 = Date.now();
34
+ try {
35
+ const r = await fn(ctx);
36
+ return { ...r, durationMs: Date.now() - t0 };
37
+ } catch (err) {
38
+ return {
39
+ status: "issue",
40
+ summary: `unexpected error: ${err.message || String(err)}`,
41
+ data: { error: String(err.message || err) },
42
+ durationMs: Date.now() - t0,
43
+ };
44
+ }
45
+ };
46
+ }
47
+
48
+ // -------- local-env -------------------------------------------------
49
+
50
+ const checkNodeVersion = {
51
+ id: "node-version",
52
+ category: "local-env",
53
+ title: `Node.js >= ${REQUIRED_NODE}`,
54
+ autoFix: "never",
55
+ dependsOn: [],
56
+ run: safeRun(async () => {
57
+ const observed = process.version;
58
+ const ok = cmpVer(observed, REQUIRED_NODE) >= 0;
59
+ return {
60
+ status: ok ? "ok" : "issue",
61
+ summary: ok ? observed : `${observed} (need >= v${REQUIRED_NODE})`,
62
+ remediation: ok ? null : "Upgrade Node via nvm/volta/asdf.",
63
+ data: { observed, required: REQUIRED_NODE },
64
+ };
65
+ }),
66
+ };
67
+
68
+ const checkConfigFileMode = {
69
+ id: "home-config-readable",
70
+ category: "local-env",
71
+ title: "~/.synapse/config.json mode 0600",
72
+ autoFix: "auto",
73
+ dependsOn: [],
74
+ run: safeRun(async (ctx) => {
75
+ const cfg = ctx.cfgPath;
76
+ if (!fs.existsSync(cfg)) {
77
+ return {
78
+ status: "warn",
79
+ summary: "no saved session",
80
+ remediation: "Run `synapse login <url>` to authenticate.",
81
+ data: { path: cfg, exists: false },
82
+ };
83
+ }
84
+ const mode = fs.statSync(cfg).mode & 0o777;
85
+ const ok = mode === 0o600 || process.platform === "win32";
86
+ return {
87
+ status: ok ? "ok" : "warn",
88
+ summary: ok ? `${cfg} (mode 0600)` : `mode 0${mode.toString(8)} (expected 0600)`,
89
+ remediation: ok ? null : `chmod 600 ${cfg}`,
90
+ data: { path: cfg, mode: mode.toString(8) },
91
+ };
92
+ }),
93
+ // Auto-fix: chmod 600.
94
+ fix: async (ctx) => {
95
+ try {
96
+ fs.chmodSync(ctx.cfgPath, 0o600);
97
+ return { kind: "applied", message: "chmod 600 applied" };
98
+ } catch (err) {
99
+ return { kind: "failed", message: err.message };
100
+ }
101
+ },
102
+ };
103
+
104
+ // -------- project --------------------------------------------------
105
+
106
+ const checkInProjectDir = {
107
+ id: "in-project-dir",
108
+ category: "project",
109
+ title: ".synapse/project.json present",
110
+ autoFix: "never",
111
+ dependsOn: [],
112
+ run: safeRun(async (ctx) => {
113
+ const exists = ctx.projectConfig !== null && ctx.projectConfig !== undefined;
114
+ if (!exists) {
115
+ return {
116
+ status: "warn",
117
+ summary: "no project metadata in this directory",
118
+ remediation: "Run `synapse select` to link this directory.",
119
+ data: { cwd: ctx.cwd, linked: false, project: null },
120
+ };
121
+ }
122
+ // v1.8.1: `doctor --fix --yes` may have written a stale marker into
123
+ // project.json. Detect it explicitly so the next doctor run says
124
+ // "linked-but-stale" instead of confidently claiming a healthy link.
125
+ if (ctx.projectConfig.staleReason === "project-not-found") {
126
+ const date = ctx.projectConfig.staleAt
127
+ ? ctx.projectConfig.staleAt.slice(0, 10)
128
+ : "previously";
129
+ return {
130
+ status: "warn",
131
+ summary: `directory was unlinked by doctor — staleReason: project-not-found (${date})`,
132
+ remediation: "Run `synapse select` to re-link.",
133
+ data: {
134
+ cwd: ctx.cwd,
135
+ linked: false,
136
+ stale: true,
137
+ previous: ctx.projectConfig.previous ?? null,
138
+ },
139
+ };
140
+ }
141
+ return {
142
+ status: "ok",
143
+ summary: `linked to ${ctx.projectConfig.project?.name || "?"}`,
144
+ data: {
145
+ cwd: ctx.cwd,
146
+ linked: true,
147
+ project: ctx.projectConfig.project?.id,
148
+ },
149
+ };
150
+ }),
151
+ };
152
+
153
+ const checkEnvLocalPresent = {
154
+ id: "env-local-present",
155
+ category: "project",
156
+ title: ".env.local exists",
157
+ autoFix: "never",
158
+ dependsOn: ["in-project-dir"],
159
+ run: safeRun(async (ctx) => {
160
+ if (!ctx.projectConfig) {
161
+ return { status: "skipped", summary: "no linked project", data: {} };
162
+ }
163
+ const p = path.join(ctx.cwd, ".env.local");
164
+ const exists = fs.existsSync(p);
165
+ return {
166
+ status: exists ? "ok" : "issue",
167
+ summary: exists ? p : "missing .env.local",
168
+ remediation: exists ? null : "Run `synapse select` — it writes .env.local.",
169
+ data: { path: p, exists },
170
+ };
171
+ }),
172
+ };
173
+
174
+ const checkEnvLocalHasVars = {
175
+ id: "env-local-has-self-hosted-vars",
176
+ category: "project",
177
+ title: "CONVEX_SELF_HOSTED_URL + ADMIN_KEY in .env.local",
178
+ autoFix: "never",
179
+ dependsOn: ["env-local-present"],
180
+ run: safeRun(async (ctx) => {
181
+ if (!ctx.projectConfig) {
182
+ return { status: "skipped", summary: "no linked project", data: {} };
183
+ }
184
+ const env = readProjectEnv(ctx.cwd);
185
+ const hasUrl = !!env.CONVEX_SELF_HOSTED_URL;
186
+ const hasKey = !!env.CONVEX_SELF_HOSTED_ADMIN_KEY;
187
+ if (hasUrl && hasKey) {
188
+ return {
189
+ status: "ok",
190
+ summary: "both vars present",
191
+ data: { hasUrl, hasKey },
192
+ };
193
+ }
194
+ return {
195
+ status: "issue",
196
+ summary: !hasUrl && !hasKey ? "both vars missing" : hasUrl ? "admin key missing" : "URL missing",
197
+ remediation: "Run `synapse select` to rewrite .env.local.",
198
+ data: { hasUrl, hasKey },
199
+ };
200
+ }),
201
+ };
202
+
203
+ const checkGitignoreProtectsEnv = {
204
+ id: "gitignore-protects-env",
205
+ category: "project",
206
+ title: ".gitignore protects .env.local and .synapse/",
207
+ autoFix: "auto",
208
+ dependsOn: [],
209
+ run: safeRun(async (ctx) => {
210
+ const p = path.join(ctx.cwd, ".gitignore");
211
+ if (!fs.existsSync(p)) {
212
+ return {
213
+ status: "warn",
214
+ summary: "no .gitignore in this directory",
215
+ remediation: "Create .gitignore with `.env.local` and `.synapse/` entries.",
216
+ data: { exists: false },
217
+ };
218
+ }
219
+ const content = fs.readFileSync(p, "utf8");
220
+ const hasEnv = /^\.env\.local\s*$/m.test(content) || /^\.env\*\.local\s*$/m.test(content);
221
+ const hasSyn = /^\.synapse\//m.test(content) || /^\.synapse\s*$/m.test(content);
222
+ if (hasEnv && hasSyn) {
223
+ return { status: "ok", summary: "both ignored", data: { hasEnv, hasSyn } };
224
+ }
225
+ const missing = [];
226
+ if (!hasEnv) missing.push(".env.local");
227
+ if (!hasSyn) missing.push(".synapse/");
228
+ return {
229
+ status: "warn",
230
+ summary: `missing entries: ${missing.join(", ")}`,
231
+ remediation: `Append to .gitignore: ${missing.join(" ")}`,
232
+ data: { hasEnv, hasSyn, missing },
233
+ };
234
+ }),
235
+ fix: async (ctx) => {
236
+ const p = path.join(ctx.cwd, ".gitignore");
237
+ let content = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
238
+ if (content && !content.endsWith("\n")) content += "\n";
239
+ const toAppend = [];
240
+ if (!/^\.env\.local\s*$/m.test(content) && !/^\.env\*\.local\s*$/m.test(content)) {
241
+ toAppend.push(".env.local");
242
+ }
243
+ if (!/^\.synapse\//m.test(content) && !/^\.synapse\s*$/m.test(content)) {
244
+ toAppend.push(".synapse/");
245
+ }
246
+ if (toAppend.length === 0) {
247
+ return { kind: "skipped", message: "already protected" };
248
+ }
249
+ content += "# added by `synapse doctor --fix`\n" + toAppend.join("\n") + "\n";
250
+ fs.writeFileSync(p, content);
251
+ return { kind: "applied", message: `appended ${toAppend.join(" + ")}` };
252
+ },
253
+ };
254
+
255
+ const checkNoShellConvexDeployment = {
256
+ id: "no-shell-convex-deployment",
257
+ category: "project",
258
+ title: "shell does NOT export CONVEX_DEPLOYMENT",
259
+ autoFix: "never",
260
+ dependsOn: [],
261
+ run: safeRun(async (ctx) => {
262
+ const v = ctx.env.CONVEX_DEPLOYMENT;
263
+ if (!v) return { status: "ok", summary: "unset", data: {} };
264
+ return {
265
+ status: "warn",
266
+ summary: `set to "${v}" — overrides .env.local`,
267
+ remediation: "Unset CONVEX_DEPLOYMENT in your shell rc, or use `synapse dev` which strips it.",
268
+ data: { value: v },
269
+ };
270
+ }),
271
+ };
272
+
273
+ // -------- backend --------------------------------------------------
274
+
275
+ const checkBackendReachable = {
276
+ id: "backend-reachable",
277
+ category: "backend",
278
+ title: "Synapse backend reachable",
279
+ autoFix: "never",
280
+ dependsOn: [],
281
+ run: safeRun(async (ctx) => {
282
+ if (!ctx.cfg) {
283
+ return {
284
+ status: "skipped",
285
+ summary: "not logged in",
286
+ remediation: "Run `synapse login <url>`.",
287
+ data: {},
288
+ };
289
+ }
290
+ const t0 = Date.now();
291
+ const probe = new SynapseAPI({ baseUrl: ctx.cfg.baseUrl });
292
+ try {
293
+ const status = await probe.request("GET", "/v1/install_status", undefined, {
294
+ auth: false,
295
+ });
296
+ const latency = Date.now() - t0;
297
+ return {
298
+ status: "ok",
299
+ summary: `${ctx.cfg.baseUrl} (v${status.version}, ${latency}ms)`,
300
+ data: {
301
+ baseUrl: ctx.cfg.baseUrl,
302
+ version: status.version,
303
+ firstRun: status.firstRun,
304
+ latencyMs: latency,
305
+ },
306
+ };
307
+ } catch (err) {
308
+ return {
309
+ status: "issue",
310
+ summary: `unreachable — ${err.message || String(err)}`,
311
+ remediation: `Check VPN/firewall. Try \`curl ${ctx.cfg.baseUrl}/v1/install_status\` from this machine.`,
312
+ data: { baseUrl: ctx.cfg.baseUrl, error: String(err.message || err) },
313
+ };
314
+ }
315
+ }),
316
+ };
317
+
318
+ const checkAuthTokenValid = {
319
+ id: "auth-token-valid",
320
+ category: "backend",
321
+ title: "session valid",
322
+ autoFix: "never",
323
+ dependsOn: ["backend-reachable"],
324
+ run: safeRun(async (ctx) => {
325
+ if (!ctx.cfg || !ctx.api) {
326
+ return { status: "skipped", summary: "not logged in", data: {} };
327
+ }
328
+ try {
329
+ const me = await ctx.api.me();
330
+ return {
331
+ status: "ok",
332
+ summary: me.email || me.user?.email || "(unknown email)",
333
+ data: { email: me.email || me.user?.email },
334
+ };
335
+ } catch (err) {
336
+ const code = err instanceof SynapseAPIError ? err.code : "unknown";
337
+ return {
338
+ status: "issue",
339
+ summary: `auth failed (${code})`,
340
+ remediation: "Run `synapse login <url>` again.",
341
+ data: { code, message: err.message },
342
+ };
343
+ }
344
+ }),
345
+ };
346
+
347
+ const checkProjectStillExists = {
348
+ id: "project-still-exists",
349
+ category: "backend",
350
+ title: "linked project exists on backend",
351
+ // v1.8.1: was "never" — promoted to "prompt" so `doctor --fix --yes`
352
+ // can auto-remediate stale .synapse/project.json. See Bug 3 in
353
+ // docs/V1_8_1_STALE_LINK_FIXES.md for the design (B-then-C hybrid).
354
+ autoFix: "prompt",
355
+ dependsOn: ["auth-token-valid", "in-project-dir"],
356
+ run: safeRun(async (ctx) => {
357
+ if (!ctx.projectConfig || !ctx.api) {
358
+ return { status: "skipped", summary: "no linked project or no session", data: {} };
359
+ }
360
+ // The marker case (stale link written by a prior --fix) has no
361
+ // project.id to look up — checkInProjectDir already warned about
362
+ // it. Skip the network call.
363
+ if (!ctx.projectConfig.project?.id) {
364
+ return { status: "skipped", summary: "no project id (stale marker?)", data: {} };
365
+ }
366
+ const teamRef = ctx.projectConfig.team?.slug || ctx.projectConfig.team?.id;
367
+ if (!teamRef) {
368
+ return { status: "warn", summary: "linked project has no team ref", data: {} };
369
+ }
370
+ try {
371
+ const projects = await ctx.api.projects(teamRef);
372
+ const found = projects.find((p) => p.id === ctx.projectConfig.project?.id);
373
+ if (found) {
374
+ return {
375
+ status: "ok",
376
+ summary: `${found.name} (${found.slug})`,
377
+ data: { id: found.id },
378
+ };
379
+ }
380
+ return {
381
+ status: "issue",
382
+ summary: "project not found in team — deleted or transferred?",
383
+ remediation: "Run `synapse select` to re-link, or `synapse doctor --fix --yes`.",
384
+ data: {
385
+ teamRef,
386
+ projectId: ctx.projectConfig.project?.id,
387
+ teamSlug: ctx.projectConfig.team?.slug,
388
+ projectSlug: ctx.projectConfig.project?.slug,
389
+ },
390
+ };
391
+ } catch (err) {
392
+ return {
393
+ status: "issue",
394
+ summary: `lookup failed: ${err.message}`,
395
+ remediation: "Check membership; project may have been deleted.",
396
+ data: { error: err.message },
397
+ };
398
+ }
399
+ }),
400
+ // Two-phase fix (Bug 3 design):
401
+ // B) If exactly one other team owns a project with the same slug,
402
+ // auto-relink (project was transferred). Deployments are reset
403
+ // because the old refs are stale — operator runs `synapse
404
+ // select` once if they want specific dev/prod refs.
405
+ // C) Otherwise (no match, ambiguous match, or any API error):
406
+ // mark project.json as stale and keep the operator's previous
407
+ // block for forensics. Append an idempotent comment marker to
408
+ // .env.local so the bogus admin key isn't silently trusted.
409
+ // Both paths are reachable only under `--fix --yes` (autoFix=prompt
410
+ // + allowPrompt=true at runner.js applyAutoFixes).
411
+ fix: async (ctx) => {
412
+ if (!ctx.projectConfig || !ctx.api) {
413
+ return { kind: "failed", message: "no project config or no API session" };
414
+ }
415
+ const savedProjectId = ctx.projectConfig.project?.id;
416
+ const savedProjectSlug = ctx.projectConfig.project?.slug;
417
+ const previous = {
418
+ team: ctx.projectConfig.team,
419
+ project: ctx.projectConfig.project,
420
+ };
421
+ // Fresh listing — never trust the upstream check's stale data.
422
+ let teams;
423
+ try {
424
+ teams = await ctx.api.teams();
425
+ } catch (err) {
426
+ return { kind: "failed", message: `could not list teams: ${err.message}` };
427
+ }
428
+ const candidates = [];
429
+ if (savedProjectSlug) {
430
+ for (const team of teams) {
431
+ let projects;
432
+ try {
433
+ projects = await ctx.api.projects(team.slug || team.id);
434
+ } catch {
435
+ continue; // one team's lookup failed; try the rest
436
+ }
437
+ for (const p of projects) {
438
+ if (p.slug === savedProjectSlug && p.id !== savedProjectId) {
439
+ candidates.push({ team, project: p });
440
+ }
441
+ }
442
+ }
443
+ }
444
+ if (candidates.length === 1) {
445
+ // Heuristic B: unambiguous re-link (most likely a transfer).
446
+ const { team, project } = candidates[0];
447
+ const newConfig = {
448
+ synapseUrl: ctx.projectConfig.synapseUrl,
449
+ team,
450
+ project,
451
+ deployments: {},
452
+ };
453
+ writeProjectConfig(ctx.cwd, newConfig);
454
+ // Sync in-memory ctx so the runner's recheck sees the new state.
455
+ // Without this, `Object.assign(r, fresh, {fixedBy})` overwrites
456
+ // with another "issue" because run() still reads the old project.id.
457
+ ctx.projectConfig = newConfig;
458
+ return {
459
+ kind: "applied",
460
+ message: `re-linked to ${team.slug || team.name}/${project.slug || project.name} (project was transferred)`,
461
+ };
462
+ }
463
+ // Fallback C: write a stale marker. Keep synapseUrl + the previous
464
+ // block so the operator can audit what was there.
465
+ const staleAt = new Date().toISOString();
466
+ const staleConfig = {
467
+ synapseUrl: ctx.projectConfig.synapseUrl,
468
+ staleReason: "project-not-found",
469
+ staleAt,
470
+ previous,
471
+ };
472
+ writeProjectConfig(ctx.cwd, staleConfig);
473
+ ctx.projectConfig = staleConfig;
474
+ // Idempotent comment marker on .env.local. The admin key inside is
475
+ // still bogus, but deletion would lose info the operator may want
476
+ // to grep, so just annotate. Marker string is stable so re-running
477
+ // fix doesn't keep appending.
478
+ try {
479
+ const envPath = path.join(ctx.cwd, ".env.local");
480
+ if (fs.existsSync(envPath)) {
481
+ const content = fs.readFileSync(envPath, "utf8");
482
+ const marker = "# stale — admin key invalid";
483
+ if (!content.includes(marker)) {
484
+ const banner = `${marker} since ${staleAt.slice(0, 10)}, run \`synapse select\`\n`;
485
+ fs.writeFileSync(envPath, banner + content);
486
+ }
487
+ }
488
+ } catch {
489
+ // Best-effort; project.json marker is the source of truth.
490
+ }
491
+ return {
492
+ kind: "applied",
493
+ message: "marked stale — run `synapse select` to re-link",
494
+ };
495
+ },
496
+ };
497
+
498
+ // -------- deployments ----------------------------------------------
499
+
500
+ function isBrowserReachable(url) {
501
+ if (!url) return false;
502
+ let u;
503
+ try {
504
+ u = new URL(url);
505
+ } catch {
506
+ return false;
507
+ }
508
+ const STANDARD = new Set(["", "443", "80", "6791"]);
509
+ if (STANDARD.has(u.port)) return true;
510
+ return u.hostname === "localhost" || u.hostname === "127.0.0.1";
511
+ }
512
+
513
+ function makeDeploymentCheck(target) {
514
+ return {
515
+ id: `deployment-${target}-health`,
516
+ category: "deployments",
517
+ title: `${target} deployment health`,
518
+ autoFix: "never",
519
+ dependsOn: ["project-still-exists"],
520
+ run: safeRun(async (ctx) => {
521
+ if (!ctx.projectConfig || !ctx.api) {
522
+ return { status: "skipped", summary: "no project or session", data: {} };
523
+ }
524
+ const ref = ctx.projectConfig.deployments?.[target];
525
+ if (!ref || !ref.name) {
526
+ return {
527
+ status: target === "dev" ? "issue" : "warn",
528
+ summary: "no deployment saved",
529
+ remediation: `Run \`synapse select\` and pick a ${target} deployment.`,
530
+ data: { target, saved: false },
531
+ };
532
+ }
533
+ let auth;
534
+ try {
535
+ auth = await ctx.api.cliCredentials(ref.name);
536
+ } catch (err) {
537
+ return {
538
+ status: "issue",
539
+ summary: `${ref.name}: credentials fetch failed (${err.message})`,
540
+ remediation: "Backend may be down, or the deployment was deleted.",
541
+ data: { name: ref.name, error: err.message },
542
+ };
543
+ }
544
+ const reachable = isBrowserReachable(auth.convexUrl);
545
+ // Probe /version on the deployment (it's a public endpoint on the
546
+ // Convex backend container). Cheap signal that the URL responds.
547
+ let probeOk = false;
548
+ let probeError = null;
549
+ let probeLatencyMs = null;
550
+ if (reachable) {
551
+ const t0 = Date.now();
552
+ try {
553
+ const ac = new AbortController();
554
+ const timeout = setTimeout(() => ac.abort(), 3500);
555
+ const res = await fetch(auth.convexUrl + "/version", {
556
+ signal: ac.signal,
557
+ });
558
+ clearTimeout(timeout);
559
+ probeOk = res.ok;
560
+ probeLatencyMs = Date.now() - t0;
561
+ } catch (err) {
562
+ probeError = err.name === "AbortError" ? "timeout (>3.5s)" : err.message;
563
+ }
564
+ }
565
+ const data = {
566
+ name: ref.name,
567
+ url: auth.convexUrl,
568
+ browserReachable: reachable,
569
+ probeOk,
570
+ probeError,
571
+ probeLatencyMs,
572
+ };
573
+ if (!reachable) {
574
+ return {
575
+ status: "issue",
576
+ summary: `${ref.name}: URL not browser-reachable (${auth.convexUrl})`,
577
+ remediation:
578
+ "Set SYNAPSE_BASE_DOMAIN on the server (wildcard subdomain) OR add a custom domain to this deployment.",
579
+ data,
580
+ };
581
+ }
582
+ if (!probeOk) {
583
+ return {
584
+ status: "warn",
585
+ summary: `${ref.name}: URL reachable but /version probe failed (${probeError ?? "no body"})`,
586
+ remediation: "TLS may still be provisioning. Retry in 30s.",
587
+ data,
588
+ };
589
+ }
590
+ return {
591
+ status: "ok",
592
+ summary: `${ref.name} — ${auth.convexUrl} (${probeLatencyMs}ms)`,
593
+ data,
594
+ };
595
+ }),
596
+ };
597
+ }
598
+
599
+ const ALL_CHECKS = [
600
+ // Tier A
601
+ checkNodeVersion,
602
+ checkConfigFileMode,
603
+ checkInProjectDir,
604
+ checkNoShellConvexDeployment,
605
+ checkGitignoreProtectsEnv,
606
+ checkBackendReachable,
607
+
608
+ // Tier B
609
+ checkAuthTokenValid,
610
+ checkEnvLocalPresent,
611
+ checkEnvLocalHasVars,
612
+ checkProjectStillExists,
613
+
614
+ // Tier C (per deployment)
615
+ makeDeploymentCheck("dev"),
616
+ makeDeploymentCheck("prod"),
617
+ ];
618
+
619
+ module.exports = { ALL_CHECKS, isBrowserReachable, cmpVer };
@@ -0,0 +1,93 @@
1
+ // Human-mode renderer for the DoctorReport. JSON mode lives in the
2
+ // command file; this is purely the terminal pretty-printer.
3
+
4
+ const colors = require("../colors");
5
+
6
+ const SYMBOL = {
7
+ ok: "✓",
8
+ warn: "!",
9
+ issue: "✗",
10
+ skipped: "·",
11
+ };
12
+ const TONE = {
13
+ ok: colors.green,
14
+ warn: colors.yellow,
15
+ issue: colors.red,
16
+ skipped: colors.dim,
17
+ };
18
+
19
+ const CATEGORY_TITLE = {
20
+ "local-env": "Local environment",
21
+ project: "Project",
22
+ backend: "Backend",
23
+ deployments: "Deployments",
24
+ upstream: "Upstream",
25
+ workspace: "Workspace",
26
+ };
27
+ const CATEGORY_ORDER = ["local-env", "project", "backend", "deployments", "upstream", "workspace"];
28
+
29
+ function renderHeader(report, write) {
30
+ const parts = ["Synapse doctor"];
31
+ if (report.env.synapseUrl) parts.push(colors.dim(`· ${report.env.synapseUrl}`));
32
+ if (report.env.user) parts.push(colors.dim(`· ${report.env.user}`));
33
+ write(parts.join(" ") + "\n\n");
34
+ }
35
+
36
+ function renderCheck(r, { verbose }, write) {
37
+ const sym = TONE[r.status](SYMBOL[r.status]);
38
+ write(` ${sym} ${r.title}`);
39
+ if (r.summary) write(colors.dim(` — ${r.summary}`));
40
+ if (r.fixedBy) write(` ${colors.cyan ? colors.cyan("↻ " + r.fixedBy) : "↻ " + r.fixedBy}`);
41
+ write("\n");
42
+ if (r.remediation && (r.status === "issue" || r.status === "warn")) {
43
+ write(colors.dim(` → ${r.remediation}\n`));
44
+ }
45
+ if (verbose && r.detail) {
46
+ write(colors.dim(` ${r.detail.replace(/\n/g, "\n ")}\n`));
47
+ }
48
+ }
49
+
50
+ function renderReport(report, { stdout, verbose = false } = {}) {
51
+ const write = (s) => stdout.write(s);
52
+ renderHeader(report, write);
53
+
54
+ const byCategory = new Map();
55
+ for (const r of report.results) {
56
+ if (!byCategory.has(r.category)) byCategory.set(r.category, []);
57
+ byCategory.get(r.category).push(r);
58
+ }
59
+
60
+ for (const cat of CATEGORY_ORDER) {
61
+ const rows = byCategory.get(cat);
62
+ if (!rows || rows.length === 0) continue;
63
+ write(` ${colors.bold(CATEGORY_TITLE[cat] ?? cat)}\n`);
64
+ for (const r of rows) renderCheck(r, { verbose }, write);
65
+ write("\n");
66
+ }
67
+
68
+ // Categories outside the canonical order (defensive — keeps the
69
+ // report honest if a check is added without updating CATEGORY_ORDER).
70
+ for (const [cat, rows] of byCategory) {
71
+ if (CATEGORY_ORDER.includes(cat)) continue;
72
+ write(` ${colors.bold(cat)}\n`);
73
+ for (const r of rows) renderCheck(r, { verbose }, write);
74
+ write("\n");
75
+ }
76
+
77
+ const t = report.totals;
78
+ const summary = [
79
+ t.ok > 0 ? colors.green(`${t.ok} ok`) : null,
80
+ t.warn > 0 ? colors.yellow(`${t.warn} warn`) : null,
81
+ t.issue > 0 ? colors.red(`${t.issue} issue${t.issue > 1 ? "s" : ""}`) : null,
82
+ t.skipped > 0 ? colors.dim(`${t.skipped} skipped`) : null,
83
+ t.fixed > 0 ? colors.cyan ? colors.cyan(`${t.fixed} fixed`) : `${t.fixed} fixed` : null,
84
+ ]
85
+ .filter(Boolean)
86
+ .join(" · ");
87
+ write(` ${summary} · ${report.durationMs}ms\n`);
88
+ if (report.exitCode !== 0) {
89
+ write(` ${colors.dim("exit " + report.exitCode)}\n`);
90
+ }
91
+ }
92
+
93
+ module.exports = { renderReport };