changebook 0.2.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,218 @@
1
+ /**
2
+ * Minimal Supabase client for the ChangeBook MCP server.
3
+ *
4
+ * Talks to PostgREST directly with the user's JWT so Row Level Security scopes
5
+ * every query to the signed-in user. Refreshes the access token with the
6
+ * refresh token when needed (refresh does not require a captcha).
7
+ */
8
+ import { loadCredentials, saveCredentials } from "./credentials.js";
9
+ // Public defaults — the anon key is the same public key the web app ships.
10
+ const DEFAULT_URL = "https://oyosihxkecspjkiligga.supabase.co";
11
+ const DEFAULT_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
12
+ export const AUTH_HELP = `No ChangeBook session configured. Run:
13
+
14
+ changebook login
15
+
16
+ It opens https://changebook.app in your browser, you click Authorize, and the
17
+ session is stored in ~/.changebook/credentials.json.
18
+
19
+ Alternative (CI / headless): set the CHANGEBOOK_REFRESH_TOKEN environment
20
+ variable (and optionally CHANGEBOOK_ACCESS_TOKEN). To extract it manually, sign
21
+ in at https://changebook.app, open the browser console and run:
22
+
23
+ JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.endsWith("-auth-token")))).refresh_token`;
24
+ export class SupabaseError extends Error {
25
+ status;
26
+ constructor(message, status) {
27
+ super(message);
28
+ this.status = status;
29
+ }
30
+ }
31
+ export class Supabase {
32
+ url;
33
+ anonKey;
34
+ accessToken;
35
+ refreshToken;
36
+ projectEnv;
37
+ projectFilterCache;
38
+ // In-flight refresh, shared by concurrent callers (single-flight).
39
+ refreshing;
40
+ // True when the tokens came from ~/.changebook/credentials.json: rotated
41
+ // refresh tokens must be written back there or the stored one goes stale.
42
+ persistRotation = false;
43
+ constructor(env = process.env) {
44
+ this.url = (env.CHANGEBOOK_SUPABASE_URL ?? DEFAULT_URL).replace(/\/+$/, "");
45
+ this.anonKey = env.CHANGEBOOK_SUPABASE_ANON_KEY ?? DEFAULT_ANON_KEY;
46
+ this.accessToken = env.CHANGEBOOK_ACCESS_TOKEN?.trim() || undefined;
47
+ this.refreshToken = env.CHANGEBOOK_REFRESH_TOKEN?.trim() || undefined;
48
+ this.projectEnv = env.CHANGEBOOK_PROJECT?.trim() || undefined;
49
+ // Env vars belong to the caller's config and win; the credentials file
50
+ // written by `changebook login` is the fallback.
51
+ if (!this.accessToken && !this.refreshToken) {
52
+ const stored = loadCredentials();
53
+ if (stored) {
54
+ this.refreshToken = stored.refresh_token;
55
+ this.accessToken = stored.access_token;
56
+ this.persistRotation = true;
57
+ }
58
+ }
59
+ }
60
+ hasCredentials() {
61
+ return Boolean(this.accessToken || this.refreshToken);
62
+ }
63
+ /**
64
+ * PostgREST fragment scoping atlas queries to the CHANGEBOOK_PROJECT env var
65
+ * (matched by slug, then by exact name). Empty when unset, which queries
66
+ * across all of the account's projects — same behavior as before projects
67
+ * existed. Resolved once per process.
68
+ */
69
+ async projectFilter() {
70
+ if (this.projectFilterCache !== undefined)
71
+ return this.projectFilterCache;
72
+ if (!this.projectEnv) {
73
+ this.projectFilterCache = "";
74
+ return this.projectFilterCache;
75
+ }
76
+ const v = encodeURIComponent(this.projectEnv);
77
+ let rows = await this.rest(`projects?select=id&slug=eq.${v}&limit=1`);
78
+ if (rows.length === 0) {
79
+ rows = await this.rest(`projects?select=id&name=eq.${v}&limit=1`);
80
+ }
81
+ if (rows.length === 0) {
82
+ throw new SupabaseError(`No ChangeBook project matches CHANGEBOOK_PROJECT="${this.projectEnv}" (by slug or name). Unset it to query all projects.`, 404);
83
+ }
84
+ this.projectFilterCache = `&project_id=eq.${rows[0].id}`;
85
+ return this.projectFilterCache;
86
+ }
87
+ /** GET a PostgREST path (e.g. "changelog?select=...") as the signed-in user. */
88
+ async rest(pathWithQuery) {
89
+ if (!this.hasCredentials())
90
+ throw new SupabaseError(AUTH_HELP, 401);
91
+ if (!this.accessToken)
92
+ await this.refresh();
93
+ let res = await this.restOnce(pathWithQuery);
94
+ if (res.status === 401 && this.refreshToken) {
95
+ await this.refresh();
96
+ res = await this.restOnce(pathWithQuery);
97
+ }
98
+ if (!res.ok) {
99
+ const body = (await res.text()).slice(0, 300);
100
+ if (res.status === 401) {
101
+ throw new SupabaseError(`Session expired and could not be refreshed. ${AUTH_HELP}`, 401);
102
+ }
103
+ throw new SupabaseError(`Supabase request failed (${res.status}): ${body}`, res.status);
104
+ }
105
+ return (await res.json());
106
+ }
107
+ restOnce(pathWithQuery) {
108
+ return fetch(`${this.url}/rest/v1/${pathWithQuery}`, {
109
+ headers: {
110
+ apikey: this.anonKey,
111
+ Authorization: `Bearer ${this.accessToken}`,
112
+ Accept: "application/json",
113
+ },
114
+ signal: AbortSignal.timeout(30_000),
115
+ });
116
+ }
117
+ refresh() {
118
+ // Single-flight: two concurrent tool calls that both hit 401 must share one
119
+ // refresh, or the second spends an already-rotated token and Supabase's
120
+ // reuse detection can revoke the whole family.
121
+ this.refreshing ??= this.doRefresh().finally(() => {
122
+ this.refreshing = undefined;
123
+ });
124
+ return this.refreshing;
125
+ }
126
+ async doRefresh() {
127
+ // The credentials file is shared across processes (the MCP server, the
128
+ // post-commit hook, a second editor window). Any of them may have rotated
129
+ // the token since we loaded it, so pick up the freshest one on disk before
130
+ // spending ours — otherwise we replay a stale token and get logged out.
131
+ if (this.persistRotation) {
132
+ const stored = loadCredentials();
133
+ if (stored?.refresh_token)
134
+ this.refreshToken = stored.refresh_token;
135
+ }
136
+ if (!this.refreshToken) {
137
+ throw new SupabaseError(`Session expired. ${AUTH_HELP}`, 401);
138
+ }
139
+ let res = await this.refreshOnce(this.refreshToken);
140
+ // If the refresh was rejected and the session came from disk, another
141
+ // process may have rotated the token in the tiny window since we read it;
142
+ // reload once and retry with the newest before giving up.
143
+ if (!res.ok && this.persistRotation) {
144
+ const stored = loadCredentials();
145
+ if (stored?.refresh_token && stored.refresh_token !== this.refreshToken) {
146
+ this.refreshToken = stored.refresh_token;
147
+ res = await this.refreshOnce(this.refreshToken);
148
+ }
149
+ }
150
+ if (!res.ok) {
151
+ const body = (await res.text()).slice(0, 300);
152
+ throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}\n\n${AUTH_HELP}`, res.status);
153
+ }
154
+ const data = (await res.json());
155
+ this.accessToken = data.access_token;
156
+ // Supabase rotates refresh tokens; keep the newest one for this process
157
+ // and, when the session came from `changebook login`, persist it on disk.
158
+ if (data.refresh_token)
159
+ this.refreshToken = data.refresh_token;
160
+ if (this.persistRotation && this.refreshToken) {
161
+ try {
162
+ saveCredentials({
163
+ refresh_token: this.refreshToken,
164
+ access_token: this.accessToken,
165
+ });
166
+ }
167
+ catch {
168
+ // Non-fatal: the in-memory session still works for this process.
169
+ }
170
+ }
171
+ }
172
+ refreshOnce(refreshToken) {
173
+ return fetch(`${this.url}/auth/v1/token?grant_type=refresh_token`, {
174
+ method: "POST",
175
+ headers: { apikey: this.anonKey, "Content-Type": "application/json" },
176
+ body: JSON.stringify({ refresh_token: refreshToken }),
177
+ signal: AbortSignal.timeout(30_000),
178
+ });
179
+ }
180
+ /**
181
+ * POST a Supabase Edge Function as the signed-in user, refreshing the
182
+ * access token once on 401. Returns status + parsed body so callers can
183
+ * surface the server's own error messages (quota, waitlist, …).
184
+ */
185
+ async invokeFunction(name, payload) {
186
+ if (!this.hasCredentials())
187
+ throw new SupabaseError(AUTH_HELP, 401);
188
+ if (!this.accessToken)
189
+ await this.refresh();
190
+ let res = await this.invokeOnce(name, payload);
191
+ if (res.status === 401 && this.refreshToken) {
192
+ await this.refresh();
193
+ res = await this.invokeOnce(name, payload);
194
+ }
195
+ const text = await res.text();
196
+ let body;
197
+ try {
198
+ body = JSON.parse(text);
199
+ }
200
+ catch {
201
+ throw new SupabaseError(`Edge function ${name} returned a non-JSON response (${res.status}): ${text.slice(0, 200)}`, res.status);
202
+ }
203
+ return { status: res.status, body };
204
+ }
205
+ invokeOnce(name, payload) {
206
+ return fetch(`${this.url}/functions/v1/${name}`, {
207
+ method: "POST",
208
+ headers: {
209
+ apikey: this.anonKey,
210
+ Authorization: `Bearer ${this.accessToken}`,
211
+ "Content-Type": "application/json",
212
+ },
213
+ body: JSON.stringify(payload),
214
+ signal: AbortSignal.timeout(120_000),
215
+ });
216
+ }
217
+ }
218
+ //# sourceMappingURL=supabase.js.map
package/dist/sync.js ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * `changebook-mcp sync` — writes/refreshes an auto-generated "product map"
3
+ * section into the project's CLAUDE.md and AGENTS.md.
4
+ *
5
+ * Why: those files are loaded into EVERY agent session (Claude Code reads
6
+ * CLAUDE.md, Codex reads AGENTS.md) and their stable content is prompt-cached,
7
+ * so a ~400-token map there orients the agent for near-zero marginal cost —
8
+ * cheaper than tool calls, paid once per cache window.
9
+ *
10
+ * The section lives between markers and is replaced in place; everything
11
+ * outside the markers is never touched.
12
+ */
13
+ import { readFile, writeFile } from "node:fs/promises";
14
+ import path from "node:path";
15
+ const START = "<!-- changebook:start -->";
16
+ const END = "<!-- changebook:end -->";
17
+ // DB text (notes, alert bodies, business impact) is AI-written and gets embedded
18
+ // between the markers that upsertSection splices on. If any of it contained a
19
+ // literal marker, the next sync would splice at the wrong offset and corrupt the
20
+ // file injected into every agent session. Break the HTML-comment opener so no
21
+ // exact marker can be forged; both markers begin with "<!--".
22
+ function sanitizeCell(text) {
23
+ return text.replace(/<!--/g, "<!- -");
24
+ }
25
+ const MAX_MODULES = 15;
26
+ const MAX_CHANGES = 5;
27
+ const MAX_COUPLINGS = 5;
28
+ // El bloque entra en CADA sesión de agente del usuario, así que tiene un
29
+ // presupuesto fijo (~500 tokens) y nunca crece sin control. Cuando no cabe
30
+ // todo, se recorta por prioridad: regresiones > acoplamientos > hotspots >
31
+ // módulos > últimos cambios.
32
+ const SYNC_BUDGET_CHARS = 2_000;
33
+ // Co-change pair thresholds — same spirit as the web's signals: at least 3
34
+ // shared analyses and a ≥60% rate before we call it a dependency.
35
+ const MIN_PAIR_COUNT = 3;
36
+ const MIN_PAIR_RATE = 0.6;
37
+ // Same window/limit the web uses for the signals strip.
38
+ const ALERT_WINDOW_DAYS = 14;
39
+ const MAX_ALERTS = 3;
40
+ export async function syncContextFiles(db, targetDir) {
41
+ const projectFilter = await db.projectFilter();
42
+ const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
43
+ const [moduleRows, changes, alerts] = await Promise.all([
44
+ db.rest("change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500" +
45
+ projectFilter),
46
+ db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
47
+ projectFilter),
48
+ // AI-detected regression warnings: best-effort — an error (older schema,
49
+ // RLS hiccup) must not block the sync of the rest of the map.
50
+ db
51
+ .rest(`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&order=created_at.desc&limit=${MAX_ALERTS}` +
52
+ projectFilter)
53
+ .catch(() => []),
54
+ ]);
55
+ const section = buildSection(moduleRows, changes, alerts);
56
+ for (const name of ["CLAUDE.md", "AGENTS.md"]) {
57
+ const file = path.join(targetDir, name);
58
+ const updated = await upsertSection(file, section);
59
+ console.error(`${updated} ${name}`);
60
+ }
61
+ }
62
+ /** Exported for tests. */
63
+ export function buildSection(rows, changes, alerts = []) {
64
+ // Newest-first rows: the first occurrence of a module is its latest state.
65
+ const seen = new Map();
66
+ for (const row of rows) {
67
+ const existing = seen.get(row.module);
68
+ if (existing)
69
+ existing.count += 1;
70
+ else
71
+ seen.set(row.module, { ...row, count: 1 });
72
+ }
73
+ const modules = [...seen.values()].slice(0, MAX_MODULES);
74
+ const head = [
75
+ START,
76
+ // Sin fecha a propósito: el bloque va al principio del CLAUDE.md/AGENTS.md
77
+ // del usuario y una fecha que cambia a diario invalidaría el prefijo de
78
+ // prompt-cache de TODO el archivo en cada sesión del agente. Las fechas
79
+ // útiles ya van por entrada (regresiones, últimos cambios).
80
+ "## Mapa del producto (ChangeBook · auto-generado)",
81
+ "",
82
+ "Este proyecto tiene memoria en ChangeBook. Antes de explorar código a mano, orienta con las tools MCP: `atlas_modules` (mapa), `atlas_recent_changes` (historia), `atlas_module_detail` (detalle + diffs).",
83
+ "",
84
+ ];
85
+ if (modules.length === 0) {
86
+ return [
87
+ ...head,
88
+ "_Aún no hay módulos analizados. Ejecuta un análisis desde la extensión o importa el historial de git._",
89
+ END,
90
+ ].join("\n");
91
+ }
92
+ const moduleLines = modules.map((m) => {
93
+ const files = Array.isArray(m.files)
94
+ ? m.files.slice(0, 3).map(String).join(", ")
95
+ : "";
96
+ const meta = [m.domain, m.risk && `riesgo ${m.risk}`]
97
+ .filter(Boolean)
98
+ .join(" · ");
99
+ const note = sanitizeCell((m.note ?? "").slice(0, 110));
100
+ return `- **${m.module}**${meta ? ` (${meta})` : ""}${files ? ` — ${files}` : ""}${note ? ` — ${note}` : ""}`;
101
+ });
102
+ // Prevention beats detection: give the agent the co-change dependencies
103
+ // and open warnings BEFORE it edits, not after something breaks.
104
+ const couplingLines = coChangePairs(rows).map((p) => `- **${p.a}** ↔ **${p.b}** — juntos en el ${Math.round(p.rate * 100)}% de sus cambios`);
105
+ // AI-detected regressions from the latest analyses: the most urgent thing
106
+ // an agent can know before editing — a change already broke a coupling.
107
+ const alertLines = alerts
108
+ .filter((a) => (a.plain ?? "").trim())
109
+ .map((a) => {
110
+ const mod = (a.module ?? "").trim();
111
+ return `- ${a.created_at.slice(0, 10)}${mod ? ` · **${mod}**` : ""} — ${sanitizeCell((a.plain ?? "").slice(0, 200))}`;
112
+ });
113
+ const hotspotLines = modules
114
+ .filter((m) => m.risk === "hotspot")
115
+ .slice(0, 5)
116
+ .map((m) => {
117
+ const note = sanitizeCell((m.note ?? "").slice(0, 110));
118
+ return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ""}`;
119
+ });
120
+ const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? "").slice(0, 140))}`);
121
+ // El orden de renderizado (legibilidad) y la prioridad de presupuesto
122
+ // (utilidad para el agente) son independientes: si no cabe todo, caen
123
+ // primero los últimos cambios y los módulos, nunca las regresiones.
124
+ const sections = [
125
+ { key: "modules", priority: 3, title: "### Módulos", lines: moduleLines },
126
+ {
127
+ key: "couplings",
128
+ priority: 1,
129
+ title: "### Módulos que cambian juntos (si tocas uno, revisa el otro)",
130
+ lines: couplingLines,
131
+ },
132
+ {
133
+ key: "alerts",
134
+ priority: 0,
135
+ title: "### Regresiones detectadas (resolver o verificar YA)",
136
+ lines: alertLines,
137
+ },
138
+ {
139
+ key: "hotspots",
140
+ priority: 2,
141
+ title: "### Avisos abiertos (revisar antes de modificar)",
142
+ lines: hotspotLines,
143
+ },
144
+ {
145
+ key: "changes",
146
+ priority: 4,
147
+ title: "### Últimos cambios",
148
+ lines: changeLines,
149
+ },
150
+ ];
151
+ let budget = SYNC_BUDGET_CHARS - head.join("\n").length - END.length;
152
+ const includedCount = new Map();
153
+ for (const s of [...sections].sort((a, b) => a.priority - b.priority)) {
154
+ if (s.lines.length === 0)
155
+ continue;
156
+ let cost = s.title.length + 2; // título + línea en blanco separadora
157
+ let count = 0;
158
+ for (const line of s.lines) {
159
+ if (cost + line.length > budget)
160
+ break;
161
+ cost += line.length + 1;
162
+ count += 1;
163
+ }
164
+ if (count > 0) {
165
+ includedCount.set(s.key, count);
166
+ budget -= cost;
167
+ }
168
+ }
169
+ const lines = [...head];
170
+ let first = true;
171
+ for (const s of sections) {
172
+ const count = includedCount.get(s.key) ?? 0;
173
+ if (count === 0)
174
+ continue;
175
+ if (!first)
176
+ lines.push("");
177
+ lines.push(s.title, ...s.lines.slice(0, count));
178
+ first = false;
179
+ }
180
+ lines.push(END);
181
+ return lines.join("\n");
182
+ }
183
+ /**
184
+ * Symmetric co-change pairs: modules that appear in the same analyses often
185
+ * enough that touching one without the other is a regression smell. Rate is
186
+ * relative to the less frequent module of the pair.
187
+ */
188
+ // Module labels contain spaces, so pair keys use a separator that cannot
189
+ // appear in a label.
190
+ const PAIR_SEP = "\u0000";
191
+ function coChangePairs(rows) {
192
+ const byAnalysis = new Map();
193
+ for (const r of rows) {
194
+ const label = (r.module ?? "").trim();
195
+ if (!label)
196
+ continue;
197
+ let set = byAnalysis.get(r.changelog_id);
198
+ if (!set) {
199
+ set = new Set();
200
+ byAnalysis.set(r.changelog_id, set);
201
+ }
202
+ set.add(label);
203
+ }
204
+ const appear = new Map();
205
+ const together = new Map();
206
+ for (const set of byAnalysis.values()) {
207
+ const mods = [...set].sort();
208
+ for (const m of mods)
209
+ appear.set(m, (appear.get(m) ?? 0) + 1);
210
+ for (let i = 0; i < mods.length; i++) {
211
+ for (let j = i + 1; j < mods.length; j++) {
212
+ const key = mods[i] + PAIR_SEP + mods[j];
213
+ together.set(key, (together.get(key) ?? 0) + 1);
214
+ }
215
+ }
216
+ }
217
+ const pairs = [];
218
+ for (const [key, count] of together) {
219
+ if (count < MIN_PAIR_COUNT)
220
+ continue;
221
+ const [a, b] = key.split(PAIR_SEP);
222
+ const rate = count / Math.min(appear.get(a) ?? 1, appear.get(b) ?? 1);
223
+ if (rate >= MIN_PAIR_RATE)
224
+ pairs.push({ a, b, rate });
225
+ }
226
+ return pairs.sort((x, y) => y.rate - x.rate).slice(0, MAX_COUPLINGS);
227
+ }
228
+ /** Exported for tests. */
229
+ export async function upsertSection(file, section) {
230
+ let content = null;
231
+ try {
232
+ content = await readFile(file, "utf8");
233
+ }
234
+ catch {
235
+ content = null;
236
+ }
237
+ if (content === null) {
238
+ await writeFile(file, section + "\n");
239
+ return "created";
240
+ }
241
+ // Migración del renombrado (AppAtlas → ChangeBook): si el archivo aún tiene
242
+ // el bloque con los marcadores antiguos, elimínalo antes de upsertar el
243
+ // nuevo — si no, quedarían dos mapas (uno obsoleto) en el mismo archivo.
244
+ const LEGACY_START = "<!-- appatlas:start -->";
245
+ const LEGACY_END = "<!-- appatlas:end -->";
246
+ const legacyStart = content.indexOf(LEGACY_START);
247
+ const legacyEnd = content.indexOf(LEGACY_END);
248
+ if (legacyStart !== -1 && legacyEnd !== -1 && legacyEnd > legacyStart) {
249
+ content = (content.slice(0, legacyStart) +
250
+ content.slice(legacyEnd + LEGACY_END.length)).replace(/\n{3,}/g, "\n\n");
251
+ await writeFile(file, content);
252
+ }
253
+ const start = content.indexOf(START);
254
+ const end = content.indexOf(END);
255
+ if (start !== -1 && end !== -1 && end > start) {
256
+ const next = content.slice(0, start) + section + content.slice(end + END.length);
257
+ // Idempotente: si el mapa no cambió, no tocar el archivo — reescribirlo
258
+ // actualizaría el mtime, ensuciaría git status y podría invalidar cachés.
259
+ if (next === content)
260
+ return "unchanged";
261
+ await writeFile(file, next);
262
+ return "updated";
263
+ }
264
+ const separator = content.endsWith("\n") ? "\n" : "\n\n";
265
+ await writeFile(file, content + separator + section + "\n");
266
+ return "appended";
267
+ }
268
+ //# sourceMappingURL=sync.js.map