@iann29/synapse 1.8.7 → 1.8.9

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,154 @@
1
+ // `synapse https status [domain]` — diagnose what's already
2
+ // configured. With a domain: deep dive on that one. Without: list
3
+ // every domain under ~/.config/dev-certs/.
4
+
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const colors = require("../colors");
8
+ const { extractFlags } = require("./_resource");
9
+ const detectMod = require("../https/detect");
10
+
11
+ function renderDomainStatus(detection, { stdout }) {
12
+ const sym = (b) => (b ? colors.green("✓") : colors.dim("·"));
13
+ stdout.write(`${colors.bold(detection.domain)}\n`);
14
+ stdout.write(
15
+ ` ${sym(detection.mkcert.present)} mkcert ${
16
+ detection.mkcert.present ? colors.dim(detection.mkcert.version) : colors.red("missing")
17
+ }\n`,
18
+ );
19
+ stdout.write(
20
+ ` ${sym(detection.caTrusted)} CA trusted${
21
+ detection.caTrusted ? "" : colors.red(" (run `mkcert -install`)")
22
+ }\n`,
23
+ );
24
+ const certPresent = detection.existingCerts && detection.existingCerts.present;
25
+ stdout.write(
26
+ ` ${sym(certPresent)} cert pair ${
27
+ certPresent ? colors.dim(detection.existingCerts.certPath) : colors.dim("(not generated yet)")
28
+ }\n`,
29
+ );
30
+ if (certPresent && detection.existingCerts.expiry) {
31
+ stdout.write(colors.dim(` expires ${detection.existingCerts.expiry}\n`));
32
+ }
33
+ const resolves = detection.resolution.resolvesToLoopback;
34
+ const src = detection.resolution.source;
35
+ stdout.write(
36
+ ` ${sym(resolves)} resolves to 127.0.0.1 ${
37
+ resolves ? colors.dim(`(via ${src})`) : colors.red("(no — add to hosts or DNS A record)")
38
+ }\n`,
39
+ );
40
+ const scriptOk = !!detection.pkg.existingDevHttps;
41
+ stdout.write(
42
+ ` ${sym(scriptOk)} package.json dev:https ${
43
+ scriptOk
44
+ ? colors.dim("set")
45
+ : detection.pkg.present
46
+ ? colors.dim("(missing in cwd's package.json)")
47
+ : colors.dim("(no package.json in cwd)")
48
+ }\n`,
49
+ );
50
+ }
51
+
52
+ module.exports = {
53
+ name: "https status",
54
+ summary: "Show local-HTTPS state for one domain or every configured domain.",
55
+ usage: "synapse https status [domain] [--json]",
56
+ description: `Without a domain: lists every cert directory in ~/.config/dev-certs/.
57
+ With a domain: prints a deep diagnostic (mkcert, CA trust, cert presence + expiry, DNS resolution, package.json script).`,
58
+
59
+ async run(args, ctx) {
60
+ const { flags, rest } = extractFlags(args, {
61
+ string: [],
62
+ boolean: ["json"],
63
+ });
64
+ const domain = rest[0];
65
+ if (rest.length > 1) {
66
+ throw new Error(`Unexpected positional: ${rest[1]}.`);
67
+ }
68
+
69
+ if (!domain) {
70
+ // List mode. We accept TWO layouts in the cert store:
71
+ // 1. Canonical (v1.8.8+): `<root>/<domain>/<domain>.pem` + `-key.pem`
72
+ // 2. Flat (operator's pre-existing intermediate layout):
73
+ // `<root>/<domain>.pem` + `<domain>{-,.}key.pem`
74
+ // Flat-format certs aren't moved automatically — they show up
75
+ // here with a `flat: true` hint so `https migrate --root` can
76
+ // reorganise them.
77
+ const root = detectMod.certsRoot();
78
+ let entries = [];
79
+ try {
80
+ entries = fs.readdirSync(root, { withFileTypes: true });
81
+ } catch {
82
+ entries = [];
83
+ }
84
+ const domains = new Set();
85
+ const flatDomains = new Set();
86
+ // (1) Subdirectories (canonical).
87
+ for (const e of entries) {
88
+ if (e.isDirectory()) domains.add(e.name);
89
+ }
90
+ // (2) Flat pairs in the root.
91
+ const flat = detectMod.detectFlatCertsInStore();
92
+ for (const p of flat) {
93
+ domains.add(p.domain);
94
+ flatDomains.add(p.domain);
95
+ }
96
+ const rows = await Promise.all(
97
+ [...domains].sort().map(async (d) => {
98
+ const det = await detectMod.scan({ domain: d, cwd: ctx.cwd });
99
+ return {
100
+ domain: d,
101
+ cert: det.existingCerts?.present ?? false,
102
+ expiry: det.existingCerts?.expiry ?? null,
103
+ resolves: det.resolution.resolvesToLoopback,
104
+ resolutionSource: det.resolution.source,
105
+ flat: flatDomains.has(d),
106
+ };
107
+ }),
108
+ );
109
+ ctx.out.result(
110
+ { root, count: rows.length, domains: rows },
111
+ (_d, { stdout }) => {
112
+ stdout.write(colors.dim(`Cert store: ${root}\n`));
113
+ if (rows.length === 0) {
114
+ stdout.write(
115
+ colors.dim("(no certs configured — run `synapse https setup <domain>`)\n"),
116
+ );
117
+ return;
118
+ }
119
+ ctx.out.table(
120
+ rows.map((r) => ({
121
+ domain: colors.bold(r.domain) + (r.flat ? colors.dim(" (flat)") : ""),
122
+ cert: r.cert ? colors.green("yes") : colors.dim("no"),
123
+ expiry: r.expiry ? colors.dim(r.expiry.slice(0, 10)) : colors.dim("?"),
124
+ resolves: r.resolves
125
+ ? colors.green(r.resolutionSource)
126
+ : colors.red("no"),
127
+ })),
128
+ [
129
+ { key: "domain", header: "DOMAIN" },
130
+ { key: "cert", header: "CERT" },
131
+ { key: "expiry", header: "EXPIRES" },
132
+ { key: "resolves", header: "LOOPBACK" },
133
+ ],
134
+ );
135
+ if (rows.some((r) => r.flat)) {
136
+ stdout.write(
137
+ colors.dim(
138
+ "\n(flat) = cert lives directly in ~/.config/dev-certs/ instead of a subdirectory. Run `synapse https migrate --root=~/.config/dev-certs` to reorganise.\n",
139
+ ),
140
+ );
141
+ }
142
+ },
143
+ );
144
+ return;
145
+ }
146
+
147
+ // Single-domain detail mode.
148
+ const det = await detectMod.scan({ domain, cwd: ctx.cwd });
149
+ ctx.out.result(det, (d, { stdout }) => renderDomainStatus(d, { stdout }));
150
+ },
151
+
152
+ // Exports for tests.
153
+ renderDomainStatus,
154
+ };
@@ -634,6 +634,238 @@ function makeDeploymentCheck(target) {
634
634
  };
635
635
  }
636
636
 
637
+ // ============================================================
638
+ // v1.8.9: Local HTTPS dev category. Checks the `synapse https setup`
639
+ // surface — mkcert presence, CA trust, cert file presence + expiry,
640
+ // hostname resolution.
641
+ //
642
+ // These checks are CONTEXT-GATED: they only emit a meaningful result
643
+ // when the operator is in a project that uses dev:https OR has
644
+ // generated certs under ~/.config/dev-certs/. When neither is true,
645
+ // every check returns `silentlySkip: true` in its data, and the
646
+ // renderer hides the whole category — operators in a "no HTTPS
647
+ // here" project don't see a section of dim "skipped" lines.
648
+ //
649
+ // We require the https detect helpers; importing them here is a
650
+ // pure dependency (no side effects at require time).
651
+ // ============================================================
652
+
653
+ const httpsDetect = require("../https/detect");
654
+
655
+ // Read the cwd's package.json dev:https script (if any) and extract
656
+ // the --hostname / --experimental-https-cert / --experimental-https-key
657
+ // values. Returns null when there's no script, malformed JSON, or
658
+ // the script isn't recognisable.
659
+ function parseDevHttpsScript(cwd) {
660
+ const info = httpsDetect.detectPackageJson(cwd);
661
+ if (!info.present || !info.existingDevHttps) return null;
662
+ const cmd = info.existingDevHttps;
663
+ const hostMatch = cmd.match(/--hostname[\s=]+(\S+)/);
664
+ const certMatch = cmd.match(/--experimental-https-cert[\s=]+(\S+)/);
665
+ const keyMatch = cmd.match(/--experimental-https-key[\s=]+(\S+)/);
666
+ if (!hostMatch || !certMatch || !keyMatch) return null;
667
+ return {
668
+ domain: hostMatch[1],
669
+ cert: certMatch[1],
670
+ key: keyMatch[1],
671
+ raw: cmd,
672
+ };
673
+ }
674
+
675
+ // "Does this operator's machine HAVE any local-HTTPS context?"
676
+ // True when either:
677
+ // - cwd has a dev:https script, OR
678
+ // - ~/.config/dev-certs/ has at least one cert pair (any shape)
679
+ //
680
+ // Used as the gate for every check in this category — when it's
681
+ // false, the checks silently skip and the category is hidden.
682
+ function hasLocalHttpsContext(cwd) {
683
+ const dev = parseDevHttpsScript(cwd);
684
+ if (dev) return true;
685
+ // Check the cert store.
686
+ try {
687
+ const fs = require("node:fs");
688
+ const root = httpsDetect.certsRoot();
689
+ if (!fs.existsSync(root)) return false;
690
+ const entries = fs.readdirSync(root, { withFileTypes: true });
691
+ // Either a subdir (canonical) or a .pem pair (flat) means context.
692
+ if (entries.some((e) => e.isDirectory())) return true;
693
+ const flat = httpsDetect.detectFlatCertsInStore();
694
+ return flat.length > 0;
695
+ } catch {
696
+ return false;
697
+ }
698
+ }
699
+
700
+ const checkHttpsMkcert = {
701
+ id: "https-mkcert",
702
+ category: "local-https-dev",
703
+ title: "mkcert installed",
704
+ autoFix: "never",
705
+ dependsOn: [],
706
+ run: safeRun(async (ctx) => {
707
+ if (!hasLocalHttpsContext(ctx.cwd)) {
708
+ return { status: "skipped", summary: "no local HTTPS context", data: { silentlySkip: true } };
709
+ }
710
+ const m = httpsDetect.detectMkcert();
711
+ if (m.present) {
712
+ return { status: "ok", summary: m.version || "(version unknown)", data: { version: m.version, caroot: m.caroot } };
713
+ }
714
+ return {
715
+ status: "issue",
716
+ summary: "mkcert not found in PATH",
717
+ remediation: "Install mkcert (apt/pacman/dnf/brew/choco/scoop/winget), then run `synapse https setup <domain>`.",
718
+ data: { present: false },
719
+ };
720
+ }),
721
+ };
722
+
723
+ const checkHttpsCaTrusted = {
724
+ id: "https-ca-trusted",
725
+ category: "local-https-dev",
726
+ title: "local CA trusted (rootCA.pem present)",
727
+ autoFix: "never",
728
+ dependsOn: ["https-mkcert"],
729
+ run: safeRun(async (ctx) => {
730
+ if (!hasLocalHttpsContext(ctx.cwd)) {
731
+ return { status: "skipped", summary: "no local HTTPS context", data: { silentlySkip: true } };
732
+ }
733
+ const m = httpsDetect.detectMkcert();
734
+ if (!m.present) {
735
+ return { status: "skipped", summary: "mkcert missing", data: {} };
736
+ }
737
+ if (httpsDetect.detectCaTrusted(m)) {
738
+ return { status: "ok", summary: `rootCA in ${m.caroot}`, data: { caroot: m.caroot } };
739
+ }
740
+ return {
741
+ status: "issue",
742
+ summary: "rootCA.pem not found in mkcert's CAROOT",
743
+ remediation: "Run `mkcert -install` once (or just `synapse https setup <domain>`).",
744
+ data: { caroot: m.caroot },
745
+ };
746
+ }),
747
+ };
748
+
749
+ const checkHttpsCertFiles = {
750
+ id: "https-cert-files",
751
+ category: "local-https-dev",
752
+ title: "dev:https cert + key files exist",
753
+ autoFix: "never",
754
+ dependsOn: ["https-mkcert"],
755
+ run: safeRun(async (ctx) => {
756
+ const dev = parseDevHttpsScript(ctx.cwd);
757
+ if (!dev) {
758
+ return { status: "skipped", summary: "no dev:https script in cwd", data: { silentlySkip: true } };
759
+ }
760
+ const fs = require("node:fs");
761
+ const certExists = fs.existsSync(dev.cert);
762
+ const keyExists = fs.existsSync(dev.key);
763
+ if (certExists && keyExists) {
764
+ return {
765
+ status: "ok",
766
+ summary: `${dev.domain} ✓ both files present`,
767
+ data: { domain: dev.domain, cert: dev.cert, key: dev.key },
768
+ };
769
+ }
770
+ const missing = [];
771
+ if (!certExists) missing.push("cert");
772
+ if (!keyExists) missing.push("key");
773
+ return {
774
+ status: "issue",
775
+ summary: `${dev.domain}: missing ${missing.join(" + ")}`,
776
+ remediation: `Run \`synapse https setup ${dev.domain}\` to regenerate.`,
777
+ data: { domain: dev.domain, cert: dev.cert, key: dev.key, certExists, keyExists },
778
+ };
779
+ }),
780
+ };
781
+
782
+ const checkHttpsDomainResolves = {
783
+ id: "https-domain-resolves",
784
+ category: "local-https-dev",
785
+ title: "dev:https domain resolves to 127.0.0.1",
786
+ autoFix: "never",
787
+ dependsOn: [],
788
+ run: safeRun(async (ctx) => {
789
+ const dev = parseDevHttpsScript(ctx.cwd);
790
+ if (!dev) {
791
+ return { status: "skipped", summary: "no dev:https script in cwd", data: { silentlySkip: true } };
792
+ }
793
+ const resolution = await httpsDetect.detectDomainResolution(dev.domain);
794
+ if (resolution.resolvesToLoopback) {
795
+ return {
796
+ status: "ok",
797
+ summary: `${dev.domain} → 127.0.0.1 (via ${resolution.source})`,
798
+ data: { domain: dev.domain, source: resolution.source },
799
+ };
800
+ }
801
+ return {
802
+ status: "issue",
803
+ summary: `${dev.domain} doesn't resolve to 127.0.0.1 (got: ${resolution.got.join(", ") || "nothing"})`,
804
+ remediation: `Run \`synapse https setup ${dev.domain}\` to add the hosts entry, OR add a DNS A record pointing the domain at 127.0.0.1.`,
805
+ data: { domain: dev.domain, got: resolution.got },
806
+ };
807
+ }),
808
+ };
809
+
810
+ const checkHttpsCertExpiry = {
811
+ id: "https-cert-expiry",
812
+ category: "local-https-dev",
813
+ title: "cert not expiring soon (>30 days)",
814
+ autoFix: "never",
815
+ dependsOn: ["https-cert-files"],
816
+ run: safeRun(async (ctx) => {
817
+ const dev = parseDevHttpsScript(ctx.cwd);
818
+ if (!dev) {
819
+ return { status: "skipped", summary: "no dev:https script in cwd", data: { silentlySkip: true } };
820
+ }
821
+ const fs = require("node:fs");
822
+ if (!fs.existsSync(dev.cert)) {
823
+ return { status: "skipped", summary: "cert file missing", data: {} };
824
+ }
825
+ if (!httpsDetect.commandExists("openssl")) {
826
+ return { status: "skipped", summary: "openssl not available — can't check expiry", data: {} };
827
+ }
828
+ const { execFileSync } = require("node:child_process");
829
+ let expiry;
830
+ try {
831
+ const out = execFileSync(
832
+ "openssl",
833
+ ["x509", "-in", dev.cert, "-noout", "-enddate"],
834
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
835
+ );
836
+ const m = out.match(/notAfter=(.+)/);
837
+ if (m) expiry = new Date(m[1]);
838
+ } catch (err) {
839
+ return { status: "skipped", summary: `openssl read failed: ${err.message}`, data: {} };
840
+ }
841
+ if (!expiry || Number.isNaN(expiry.getTime())) {
842
+ return { status: "skipped", summary: "could not parse cert expiry", data: {} };
843
+ }
844
+ const daysLeft = Math.floor((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
845
+ if (daysLeft < 0) {
846
+ return {
847
+ status: "issue",
848
+ summary: `${dev.domain}: cert EXPIRED ${-daysLeft} days ago (${expiry.toISOString().slice(0, 10)})`,
849
+ remediation: `Regenerate: \`synapse https setup ${dev.domain} --force\`.`,
850
+ data: { domain: dev.domain, expiry: expiry.toISOString(), daysLeft },
851
+ };
852
+ }
853
+ if (daysLeft < 30) {
854
+ return {
855
+ status: "warn",
856
+ summary: `${dev.domain}: cert expires in ${daysLeft} days (${expiry.toISOString().slice(0, 10)})`,
857
+ remediation: `Regenerate soon: \`synapse https setup ${dev.domain} --force\`.`,
858
+ data: { domain: dev.domain, expiry: expiry.toISOString(), daysLeft },
859
+ };
860
+ }
861
+ return {
862
+ status: "ok",
863
+ summary: `${dev.domain}: ${daysLeft} days left (expires ${expiry.toISOString().slice(0, 10)})`,
864
+ data: { domain: dev.domain, expiry: expiry.toISOString(), daysLeft },
865
+ };
866
+ }),
867
+ };
868
+
637
869
  const ALL_CHECKS = [
638
870
  // Tier A
639
871
  checkNodeVersion,
@@ -653,6 +885,22 @@ const ALL_CHECKS = [
653
885
  // Tier C (per deployment)
654
886
  makeDeploymentCheck("dev"),
655
887
  makeDeploymentCheck("prod"),
888
+
889
+ // Tier D — local HTTPS dev (v1.8.9). Context-gated: if the cwd
890
+ // doesn't have a dev:https script AND no certs are configured, the
891
+ // whole category is hidden from the report.
892
+ checkHttpsMkcert,
893
+ checkHttpsCaTrusted,
894
+ checkHttpsCertFiles,
895
+ checkHttpsDomainResolves,
896
+ checkHttpsCertExpiry,
656
897
  ];
657
898
 
658
- module.exports = { ALL_CHECKS, isBrowserReachable, cmpVer };
899
+ module.exports = {
900
+ ALL_CHECKS,
901
+ isBrowserReachable,
902
+ cmpVer,
903
+ // exports for tests
904
+ parseDevHttpsScript,
905
+ hasLocalHttpsContext,
906
+ };
@@ -21,10 +21,19 @@ const CATEGORY_TITLE = {
21
21
  project: "Project",
22
22
  backend: "Backend",
23
23
  deployments: "Deployments",
24
+ "local-https-dev": "Local HTTPS dev",
24
25
  upstream: "Upstream",
25
26
  workspace: "Workspace",
26
27
  };
27
- const CATEGORY_ORDER = ["local-env", "project", "backend", "deployments", "upstream", "workspace"];
28
+ const CATEGORY_ORDER = [
29
+ "local-env",
30
+ "project",
31
+ "backend",
32
+ "deployments",
33
+ "local-https-dev",
34
+ "upstream",
35
+ "workspace",
36
+ ];
28
37
 
29
38
  function renderHeader(report, write) {
30
39
  const parts = ["Synapse doctor"];
@@ -74,8 +83,16 @@ function renderReport(report, { stdout, verbose = false } = {}) {
74
83
  for (const cat of CATEGORY_ORDER) {
75
84
  const rows = byCategory.get(cat);
76
85
  if (!rows || rows.length === 0) continue;
86
+ // v1.8.9: filter out individual results marked `silentlySkip`
87
+ // (checks that decided their context is missing — e.g. `dev:https`
88
+ // checks in a project without that script). Then hide the
89
+ // category if NOTHING is left.
90
+ const visibleRows = rows.filter(
91
+ (r) => !(r.status === "skipped" && r.data && r.data.silentlySkip),
92
+ );
93
+ if (visibleRows.length === 0) continue;
77
94
  write(` ${colors.bold(CATEGORY_TITLE[cat] ?? cat)}\n`);
78
- for (const r of rows) renderCheck(r, { verbose }, write);
95
+ for (const r of visibleRows) renderCheck(r, { verbose }, write);
79
96
  write("\n");
80
97
  }
81
98
 
@@ -39,13 +39,22 @@ async function runChecks(checks, ctx) {
39
39
  return r.status === "issue" || r.status === "skipped";
40
40
  });
41
41
  if (failedDeps.length > 0) {
42
+ // v1.8.9: propagate `silentlySkip` from any silent-skipped
43
+ // prereq so the cascade-skipped child stays hidden too.
44
+ // Without this, https-cert-expiry would re-surface as a
45
+ // visible "·" every time its parent (https-cert-files) was
46
+ // silently skipped for "no dev:https script in cwd".
47
+ const silentParent = failedDeps.some((d) => {
48
+ const parent = resultsById.get(d);
49
+ return parent && parent.data && parent.data.silentlySkip;
50
+ });
42
51
  resultsById.set(id, {
43
52
  id,
44
53
  category: check.category,
45
54
  title: check.title,
46
55
  status: "skipped",
47
56
  summary: `skipped (prereq failed: ${failedDeps.join(", ")})`,
48
- data: { skippedBecause: failedDeps },
57
+ data: { skippedBecause: failedDeps, silentlySkip: silentParent },
49
58
  durationMs: 0,
50
59
  });
51
60
  pending.delete(id);