@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,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 };
@@ -0,0 +1,148 @@
1
+ // Runs the doctor checks with the DAG defined in checks.js (each
2
+ // check declares its `dependsOn` ids). Within a tier, checks run in
3
+ // parallel via Promise.all; dependent checks wait on their prereqs.
4
+ //
5
+ // The orchestrator produces a stable DoctorReport — same tree feeds
6
+ // both the human renderer and `--json`.
7
+
8
+ const { ALL_CHECKS } = require("./checks");
9
+
10
+ const SCHEMA_VERSION = "1.0";
11
+
12
+ async function runChecks(checks, ctx) {
13
+ // Topological execution: for each check, wait for its dependencies
14
+ // (already-resolved CheckResults) before invoking run(). Failed or
15
+ // skipped prereqs cascade to skipped state (don't run the dependent).
16
+ const resultsById = new Map();
17
+ const order = []; // preserve insertion order for the final report
18
+ // Resolve sequentially through the catalog — checks declared earlier
19
+ // become available as deps for later ones. Inside a "tier" we still
20
+ // get the parallel benefit because awaiting an already-resolved
21
+ // result is a microtask, not a roundtrip.
22
+ for (const check of checks) {
23
+ order.push(check.id);
24
+ }
25
+
26
+ // First pass: kick off every check whose dependencies are all "ok"
27
+ // or "warn"; cascade-skip the rest.
28
+ const pending = new Map();
29
+ for (const check of checks) {
30
+ pending.set(check.id, check);
31
+ }
32
+ while (pending.size > 0) {
33
+ let advanced = false;
34
+ for (const [id, check] of pending) {
35
+ const depsResolved = (check.dependsOn || []).every((d) => resultsById.has(d));
36
+ if (!depsResolved) continue;
37
+ const failedDeps = (check.dependsOn || []).filter((d) => {
38
+ const r = resultsById.get(d);
39
+ return r.status === "issue" || r.status === "skipped";
40
+ });
41
+ if (failedDeps.length > 0) {
42
+ resultsById.set(id, {
43
+ id,
44
+ category: check.category,
45
+ title: check.title,
46
+ status: "skipped",
47
+ summary: `skipped (prereq failed: ${failedDeps.join(", ")})`,
48
+ data: { skippedBecause: failedDeps },
49
+ durationMs: 0,
50
+ });
51
+ pending.delete(id);
52
+ advanced = true;
53
+ continue;
54
+ }
55
+ const result = await check.run(ctx);
56
+ resultsById.set(id, {
57
+ id,
58
+ category: check.category,
59
+ title: check.title,
60
+ ...result,
61
+ });
62
+ pending.delete(id);
63
+ advanced = true;
64
+ }
65
+ if (!advanced) {
66
+ // Cycle in deps — should never happen; bail to avoid an infinite
67
+ // loop. Mark anything still pending as a hard error.
68
+ for (const [id, check] of pending) {
69
+ resultsById.set(id, {
70
+ id,
71
+ category: check.category,
72
+ title: check.title,
73
+ status: "issue",
74
+ summary: `cycle in dependsOn: ${(check.dependsOn || []).join(", ")}`,
75
+ durationMs: 0,
76
+ });
77
+ }
78
+ break;
79
+ }
80
+ }
81
+
82
+ return order.map((id) => resultsById.get(id));
83
+ }
84
+
85
+ function totalize(results) {
86
+ const t = { ok: 0, warn: 0, issue: 0, skipped: 0, fixed: 0 };
87
+ for (const r of results) {
88
+ if (r.fixedBy) t.fixed += 1;
89
+ if (r.status === "ok") t.ok += 1;
90
+ else if (r.status === "warn") t.warn += 1;
91
+ else if (r.status === "issue") t.issue += 1;
92
+ else if (r.status === "skipped") t.skipped += 1;
93
+ }
94
+ return t;
95
+ }
96
+
97
+ function exitCodeFor(totals) {
98
+ if (totals.issue > 0) return 2;
99
+ if (totals.warn > 0) return 1;
100
+ return 0;
101
+ }
102
+
103
+ async function applyAutoFixes(results, checks, ctx, { allowPrompt = false } = {}) {
104
+ // Walk results, find non-ok ones whose check has autoFix=auto (or
105
+ // prompt + allowPrompt=true), invoke the fix, re-run THAT check.
106
+ // Stays simple: one fix pass, no recursive heal-then-recheck loops.
107
+ const byId = new Map(checks.map((c) => [c.id, c]));
108
+ for (const r of results) {
109
+ if (r.status === "ok" || r.status === "skipped") continue;
110
+ const check = byId.get(r.id);
111
+ if (!check || typeof check.fix !== "function") continue;
112
+ if (check.autoFix === "never") continue;
113
+ if (check.autoFix === "prompt" && !allowPrompt) continue;
114
+ const fixOutcome = await check.fix(ctx, r);
115
+ if (fixOutcome.kind === "applied") {
116
+ // Re-run the check; if it's green now, mark fixedBy.
117
+ const fresh = await check.run(ctx);
118
+ Object.assign(r, fresh, { fixedBy: fixOutcome.message });
119
+ } else if (fixOutcome.kind === "failed") {
120
+ r.detail = (r.detail ? r.detail + "\n" : "") + `fix attempt failed: ${fixOutcome.message}`;
121
+ }
122
+ }
123
+ }
124
+
125
+ async function generateReport(ctx, { fix = false, allowPrompt = false } = {}) {
126
+ const t0 = Date.now();
127
+ const results = await runChecks(ALL_CHECKS, ctx);
128
+ if (fix) {
129
+ await applyAutoFixes(results, ALL_CHECKS, ctx, { allowPrompt });
130
+ }
131
+ const totals = totalize(results);
132
+ const exitCode = exitCodeFor(totals);
133
+ return {
134
+ schemaVersion: SCHEMA_VERSION,
135
+ cliVersion: require("../../package.json").version,
136
+ env: {
137
+ cwd: ctx.cwd,
138
+ synapseUrl: ctx.cfg?.baseUrl ?? null,
139
+ user: ctx.cfg?.user?.email ?? null,
140
+ },
141
+ results,
142
+ totals,
143
+ durationMs: Date.now() - t0,
144
+ exitCode,
145
+ };
146
+ }
147
+
148
+ module.exports = { runChecks, totalize, exitCodeFor, applyAutoFixes, generateReport, SCHEMA_VERSION };
package/lib/output.js ADDED
@@ -0,0 +1,140 @@
1
+ // Shared output layer for every synapse CLI command.
2
+ //
3
+ // Every handler writes through `createOutput()` instead of poking
4
+ // process.stdout directly. This is the only thing that makes `--json`
5
+ // honest: in JSON mode the renderer never emits ANSI codes, partial
6
+ // human strings, or stray progress lines that would corrupt a piped
7
+ // script's parse.
8
+ //
9
+ // Contract:
10
+ // - `result(data, renderHuman)` is THE command result. In JSON mode
11
+ // emits `JSON.stringify(data)` to stdout. In human mode invokes
12
+ // renderHuman(data, ctx).
13
+ // - `info`/`warn` are side-channel status (progress, hints). They go
14
+ // to stderr and are SUPPRESSED entirely in JSON mode so a piped
15
+ // `--json` stream stays parse-clean.
16
+ // - `error(msg, { hint })` writes to stderr. In JSON mode emits
17
+ // `{error, hint}` so scripts can branch on it.
18
+ // - `table(rows, columns)` is a small helper that lays out aligned
19
+ // columns in human mode; in JSON mode the caller passes `rows`
20
+ // directly to `result()` instead.
21
+
22
+ const colors = require("./colors");
23
+
24
+ function createOutput({
25
+ json = false,
26
+ stdout = process.stdout,
27
+ stderr = process.stderr,
28
+ } = {}) {
29
+ function writeOut(s) {
30
+ stdout.write(s);
31
+ }
32
+ function writeErr(s) {
33
+ stderr.write(s);
34
+ }
35
+
36
+ return {
37
+ json,
38
+ stdout,
39
+ stderr,
40
+
41
+ // The command's structured result. JSON mode emits a single line.
42
+ // Human mode delegates rendering — the renderer is free to write
43
+ // tables, multi-line copy, colors, anything. Callers MUST supply
44
+ // a renderer; commands with no useful human output should pass a
45
+ // no-op (`() => {}`) explicitly so the JSON-vs-human split stays
46
+ // obvious in code review.
47
+ result(data, renderHuman) {
48
+ if (this.json) {
49
+ writeOut(JSON.stringify(data) + "\n");
50
+ return;
51
+ }
52
+ renderHuman(data, { stdout, stderr });
53
+ },
54
+
55
+ // Progress / status. Suppressed under --json so a piped consumer
56
+ // never sees mixed JSON and prose on stdout.
57
+ info(msg) {
58
+ if (this.json) return;
59
+ writeErr(msg + "\n");
60
+ },
61
+
62
+ warn(msg) {
63
+ if (this.json) return;
64
+ writeErr(colors.yellow("Warning: ") + msg + "\n");
65
+ },
66
+
67
+ // Fatal-ish error message. Returns nothing — callers throw or
68
+ // set process.exitCode on their own to control control flow.
69
+ // The dispatcher's top-level catch is the canonical exit path.
70
+ error(msg, { hint } = {}) {
71
+ if (this.json) {
72
+ writeErr(JSON.stringify({ error: msg, ...(hint ? { hint } : {}) }) + "\n");
73
+ return;
74
+ }
75
+ writeErr(colors.red("Error: ") + msg + "\n");
76
+ if (hint) writeErr(colors.dim("Hint: ") + hint + "\n");
77
+ },
78
+
79
+ // Lightweight column-aligned table renderer for human mode.
80
+ // `columns` is [{ key, header, align?, color? }]. `align` is
81
+ // "left" (default) or "right". `color` is a colors.* function
82
+ // applied per-cell after the value is stringified.
83
+ //
84
+ // In JSON mode the function is a no-op — callers should hand the
85
+ // raw rows array to `result()` instead.
86
+ table(rows, columns) {
87
+ if (this.json) return;
88
+ if (!rows || rows.length === 0) {
89
+ writeOut(colors.dim("(no rows)\n"));
90
+ return;
91
+ }
92
+ const widths = columns.map((c) => c.header.length);
93
+ const cells = rows.map((row) =>
94
+ columns.map((c, i) => {
95
+ const raw = row[c.key];
96
+ const text = raw == null ? "" : String(raw);
97
+ if (text.length > widths[i]) widths[i] = text.length;
98
+ return text;
99
+ }),
100
+ );
101
+ const fmt = (text, w, align) =>
102
+ align === "right" ? text.padStart(w) : text.padEnd(w);
103
+ const header = columns
104
+ .map((c, i) => colors.dim(fmt(c.header, widths[i], c.align)))
105
+ .join(" ");
106
+ writeOut(header + "\n");
107
+ writeOut(
108
+ colors.dim(widths.map((w) => "─".repeat(w)).join(" ")) + "\n",
109
+ );
110
+ for (const row of cells) {
111
+ const line = row
112
+ .map((cell, i) => {
113
+ const padded = fmt(cell, widths[i], columns[i].align);
114
+ return columns[i].color ? columns[i].color(padded) : padded;
115
+ })
116
+ .join(" ");
117
+ writeOut(line + "\n");
118
+ }
119
+ },
120
+ };
121
+ }
122
+
123
+ // Detects `--json` from an args vector and returns
124
+ // `{ json: boolean, rest: string[] }` so handlers can strip the flag
125
+ // before parsing their own positionals. Bash/CI scripts commonly
126
+ // pass `--json` in any position; we accept it anywhere.
127
+ function extractJsonFlag(argv) {
128
+ const rest = [];
129
+ let json = false;
130
+ for (const a of argv) {
131
+ if (a === "--json") {
132
+ json = true;
133
+ } else {
134
+ rest.push(a);
135
+ }
136
+ }
137
+ return { json, rest };
138
+ }
139
+
140
+ module.exports = { createOutput, extractJsonFlag };
package/lib/prompts.js CHANGED
@@ -1,5 +1,10 @@
1
1
  const readline = require("node:readline");
2
2
 
3
+ // Sentinel returned by `choose` when the user asks to go back to the
4
+ // previous step. Use a Symbol so it can never collide with a legitimate
5
+ // choice payload.
6
+ const BACK = Symbol("synapse-choose-back");
7
+
3
8
  function ask(question, { input = process.stdin, output = process.stderr } = {}) {
4
9
  const rl = readline.createInterface({ input, output });
5
10
  return new Promise((resolve) => {
@@ -89,12 +94,69 @@ async function askCredentials({ input = process.stdin, output = process.stderr }
89
94
  };
90
95
  }
91
96
 
92
- async function choose(label, choices, { input = process.stdin, output = process.stderr } = {}) {
97
+ // confirm prompts the user with a yes/no question and resolves to a boolean.
98
+ //
99
+ // - Empty answer applies `defaultAnswer` (so `[y/N]` matches "no by default"
100
+ // and `[Y/n]` matches "yes by default"). The hint in the question is the
101
+ // caller's responsibility — we render the prompt literally.
102
+ // - "y" / "yes" / "n" / "no" (case-insensitive) are accepted.
103
+ // - Anything else re-prompts with a tip, capped at `maxAttempts` to keep
104
+ // pasted-shell-history scenarios from looping forever.
105
+ // - Non-interactive callers (no TTY) cannot disambiguate — they must use a
106
+ // skip-confirmation flag at the call site instead of relying on `confirm`.
107
+ //
108
+ // We open a single readline interface for the whole prompt session.
109
+ // Calling `ask()` multiple times would re-create the interface and re-bind
110
+ // `data` listeners on the input stream — which is fine for real stdin but
111
+ // produces flaky behaviour on a buffered PassThrough where the second
112
+ // listener can't see data the first consumed. One rl, multiple questions.
113
+ async function confirm(question, {
114
+ input = process.stdin,
115
+ output = process.stderr,
116
+ defaultAnswer = false,
117
+ maxAttempts = 3,
118
+ } = {}) {
119
+ const rl = readline.createInterface({ input, output });
120
+ try {
121
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
122
+ const raw = await new Promise((resolve) => rl.question(question, resolve));
123
+ const answer = raw.trim().toLowerCase();
124
+ if (answer === "") return defaultAnswer;
125
+ if (answer === "y" || answer === "yes") return true;
126
+ if (answer === "n" || answer === "no") return false;
127
+ output.write("Please answer y or n.\n");
128
+ }
129
+ throw new Error("Confirmation prompt cancelled after too many invalid answers");
130
+ } finally {
131
+ rl.close();
132
+ }
133
+ }
134
+
135
+ // choose prompts the user to pick one of `choices`.
136
+ //
137
+ // - `label` is the plural noun used for the menu header ("dev deployments").
138
+ // - `singularLabel` defaults to `label` stripped of a trailing "s" — it's
139
+ // the noun used when only one option exists and we auto-select silently.
140
+ // Pass a custom value when the strip heuristic produces something
141
+ // awkward ("members" → "member" is fine; "people" → "peopl" is not).
142
+ // - `allowBack` lets the user type "b" / "back" / "0" to return the BACK
143
+ // sentinel, which the caller maps to a previous step.
144
+ // - `maxInvalid` caps how many garbage answers in a row we tolerate
145
+ // before throwing. Protects against pasted shell history / broken
146
+ // stdin where the loop would otherwise spin forever.
147
+ async function choose(label, choices, {
148
+ input = process.stdin,
149
+ output = process.stderr,
150
+ singularLabel,
151
+ allowBack = false,
152
+ maxInvalid = 3,
153
+ } = {}) {
93
154
  if (!Array.isArray(choices) || choices.length === 0) {
94
155
  throw new Error(`No ${label} available.`);
95
156
  }
157
+ const singular = singularLabel || label.replace(/s$/, "") || label;
96
158
  if (choices.length === 1) {
97
- output.write(`Using ${label}: ${choices[0].label}\n`);
159
+ output.write(`Auto-selected ${singular}: ${choices[0].label} (only one available)\n`);
98
160
  return choices[0].value;
99
161
  }
100
162
 
@@ -103,20 +165,34 @@ async function choose(label, choices, { input = process.stdin, output = process.
103
165
  output.write(` ${index + 1}. ${choice.label}\n`);
104
166
  });
105
167
 
168
+ const hint = allowBack
169
+ ? `[1-${choices.length}, b=back]`
170
+ : `[1-${choices.length}]`;
171
+
172
+ let invalid = 0;
106
173
  while (true) {
107
- const answer = await ask(`Choose ${label} [1-${choices.length}]: `, { input, output });
174
+ const answer = (await ask(`Choose ${label} ${hint}: `, { input, output })).trim();
175
+ if (allowBack && (answer === "b" || answer === "B" || answer === "back" || answer === "0")) {
176
+ return BACK;
177
+ }
108
178
  const n = Number.parseInt(answer, 10);
109
179
  if (Number.isInteger(n) && n >= 1 && n <= choices.length) {
110
180
  return choices[n - 1].value;
111
181
  }
112
- output.write(`Enter a number from 1 to ${choices.length}.\n`);
182
+ invalid += 1;
183
+ if (invalid >= maxInvalid) {
184
+ throw new Error(`Cancelled ${label} prompt after ${invalid} invalid answers`);
185
+ }
186
+ output.write(`Enter a number from 1 to ${choices.length}${allowBack ? " (or 'b' to go back)" : ""}.\n`);
113
187
  }
114
188
  }
115
189
 
116
190
  module.exports = {
191
+ BACK,
117
192
  ask,
118
193
  askCredentials,
119
194
  askHidden,
120
195
  choose,
196
+ confirm,
121
197
  parseCredentialsInput,
122
198
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iann29/synapse",
3
- "version": "1.6.17",
3
+ "version": "1.8.0",
4
4
  "description": "Thin CLI wrapper for using the official Convex CLI with Synapse-managed deployments.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {