@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,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/project.js CHANGED
@@ -50,12 +50,20 @@ function sanitizeProjectConfig(input) {
50
50
  if (input.deployments?.prod) {
51
51
  deployments.prod = deploymentRef(input.deployments.prod);
52
52
  }
53
+ // v1.8.1: `doctor --fix --yes` can mark a project.json stale when the
54
+ // linked project was deleted/transferred (see Bug 3). The marker
55
+ // fields (staleReason / staleAt / previous) need to round-trip
56
+ // through writeProjectConfig — without these allowlist entries
57
+ // sanitizeProjectConfig would strip them on every write.
53
58
  return compactObject({
54
59
  version: 1,
55
60
  synapseUrl: input.synapseUrl,
56
61
  team: entityRef(input.team),
57
62
  project: entityRef(input.project),
58
63
  deployments,
64
+ staleReason: input.staleReason,
65
+ staleAt: input.staleAt,
66
+ previous: input.previous,
59
67
  });
60
68
  }
61
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iann29/synapse",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
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": {