changebook 0.4.6 → 0.4.7

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.
@@ -61,4 +61,75 @@ export function clearCredentials() {
61
61
  return false;
62
62
  }
63
63
  }
64
+ const LOCK_FILE = path.join(DIR, "refresh.lock");
65
+ function sleep(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+ /**
69
+ * Run `fn` while holding an exclusive, cross-process lock.
70
+ *
71
+ * Supabase rotates the refresh token on every use, and its reuse-detection
72
+ * revokes the WHOLE token family if a rotated token is ever replayed. The
73
+ * post-commit `analyze` runs detached in the background, so it can still be
74
+ * refreshing when the next commit's pre-commit `guard` (or another analyze)
75
+ * starts — two processes then spend the same stored refresh token and the
76
+ * account is logged out silently. A lockfile serializes the refresh across
77
+ * every process that shares the credentials file, so only one spends the token.
78
+ *
79
+ * Falls through and runs `fn` unlocked if the lock can't be taken within
80
+ * `timeoutMs` — a hung holder must never block a commit forever; that only
81
+ * degrades to the previous lock-less behavior. A stale lock left by a crashed
82
+ * holder (older than `staleMs`) is stolen.
83
+ */
84
+ export async function withCredentialsLock(fn, opts = {}) {
85
+ const lockPath = opts.lockPath ?? LOCK_FILE;
86
+ const timeoutMs = opts.timeoutMs ?? 30_000;
87
+ const staleMs = opts.staleMs ?? 60_000;
88
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
89
+ const deadline = Date.now() + timeoutMs;
90
+ let held = false;
91
+ for (;;) {
92
+ try {
93
+ const fd = fs.openSync(lockPath, "wx", 0o600);
94
+ fs.writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
95
+ fs.closeSync(fd);
96
+ held = true;
97
+ break;
98
+ }
99
+ catch (err) {
100
+ if (err.code !== "EEXIST")
101
+ throw err;
102
+ // Held by another process. Steal it only if it is stale (crashed holder).
103
+ let stolen = false;
104
+ try {
105
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) {
106
+ fs.rmSync(lockPath, { force: true });
107
+ stolen = true;
108
+ }
109
+ }
110
+ catch {
111
+ // The lock vanished between open and stat: retry immediately.
112
+ stolen = true;
113
+ }
114
+ if (stolen)
115
+ continue;
116
+ if (Date.now() >= deadline)
117
+ break; // give up waiting; proceed unlocked
118
+ await sleep(50);
119
+ }
120
+ }
121
+ try {
122
+ return await fn();
123
+ }
124
+ finally {
125
+ if (held) {
126
+ try {
127
+ fs.rmSync(lockPath, { force: true });
128
+ }
129
+ catch {
130
+ // Best-effort release; a leftover lock is stolen once it goes stale.
131
+ }
132
+ }
133
+ }
134
+ }
64
135
  //# sourceMappingURL=credentials.js.map
package/dist/supabase.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * every query to the signed-in user. Refreshes the access token with the
6
6
  * refresh token when needed (refresh does not require a captcha).
7
7
  */
8
- import { loadCredentials, saveCredentials } from './credentials.js';
8
+ import { loadCredentials, saveCredentials, withCredentialsLock, } from './credentials.js';
9
9
  import { slugifyProject } from './guard.js';
10
10
  // Public defaults — the anon key is the same public key the web app ships.
11
11
  const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
@@ -22,6 +22,29 @@ variable (and optionally CHANGEBOOK_ACCESS_TOKEN). To extract it manually, sign
22
22
  in at https://changebook.app, open the browser console and run:
23
23
 
24
24
  JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.endsWith("-auth-token")))).refresh_token`;
25
+ /**
26
+ * Decide what to do at the start of a refresh, given the refresh token this
27
+ * process held before it took the lock and whatever is on disk now.
28
+ *
29
+ * If another process refreshed while we waited for the lock, the stored refresh
30
+ * token differs from ours AND comes with a fresh access token: adopt those and
31
+ * never spend our own. Replaying a rotated token trips Supabase's
32
+ * reuse-detection and revokes the whole family — the silent logout this guards
33
+ * against. Otherwise spend the freshest refresh token available.
34
+ */
35
+ export function decideRefresh(priorRefreshToken, stored) {
36
+ if (stored?.refresh_token &&
37
+ stored.refresh_token !== priorRefreshToken &&
38
+ stored.access_token) {
39
+ return {
40
+ kind: 'adopt',
41
+ access_token: stored.access_token,
42
+ refresh_token: stored.refresh_token,
43
+ };
44
+ }
45
+ const token = stored?.refresh_token ?? priorRefreshToken;
46
+ return token ? { kind: 'spend', refresh_token: token } : { kind: 'none' };
47
+ }
25
48
  export class SupabaseError extends Error {
26
49
  status;
27
50
  constructor(message, status) {
@@ -264,30 +287,35 @@ export class Supabase {
264
287
  });
265
288
  return this.refreshing;
266
289
  }
267
- async doRefresh() {
268
- // The credentials file is shared across processes (the MCP server, the
269
- // post-commit hook, a second editor window). Any of them may have rotated
270
- // the token since we loaded it, so pick up the freshest one on disk before
271
- // spending ours — otherwise we replay a stale token and get logged out.
272
- if (this.persistRotation) {
273
- const stored = loadCredentials();
274
- if (stored?.refresh_token)
275
- this.refreshToken = stored.refresh_token;
276
- }
277
- if (!this.refreshToken) {
290
+ doRefresh() {
291
+ // Env-var sessions aren't shared through the credentials file, so there is
292
+ // nothing to coordinate between processes: refresh in place.
293
+ if (!this.persistRotation)
294
+ return this.doRefreshInner();
295
+ // Disk-backed sessions (from `changebook login`) can be refreshed by
296
+ // several processes at once — the detached post-commit analyze, the
297
+ // pre-commit guard, a second editor window. Serialize under a cross-process
298
+ // lock so only one spends the rotating token; the others adopt its result
299
+ // instead of replaying a token Supabase has already rotated (which would
300
+ // trip reuse-detection and log the whole account out silently).
301
+ return withCredentialsLock(() => this.doRefreshInner());
302
+ }
303
+ async doRefreshInner() {
304
+ // Under the lock: re-read disk so a refresh another process just completed
305
+ // is picked up before we spend anything.
306
+ const stored = this.persistRotation ? loadCredentials() : null;
307
+ const plan = decideRefresh(this.refreshToken, stored);
308
+ if (plan.kind === 'none') {
278
309
  throw new SupabaseError(`Session expired. ${AUTH_HELP}`, 401);
279
310
  }
280
- let res = await this.refreshOnce(this.refreshToken);
281
- // If the refresh was rejected and the session came from disk, another
282
- // process may have rotated the token in the tiny window since we read it;
283
- // reload once and retry with the newest before giving up.
284
- if (!res.ok && this.persistRotation) {
285
- const stored = loadCredentials();
286
- if (stored?.refresh_token && stored.refresh_token !== this.refreshToken) {
287
- this.refreshToken = stored.refresh_token;
288
- res = await this.refreshOnce(this.refreshToken);
289
- }
311
+ if (plan.kind === 'adopt') {
312
+ // Another process refreshed while we waited: use its fresh tokens.
313
+ this.accessToken = plan.access_token;
314
+ this.refreshToken = plan.refresh_token;
315
+ return;
290
316
  }
317
+ this.refreshToken = plan.refresh_token;
318
+ const res = await this.refreshOnce(this.refreshToken);
291
319
  if (!res.ok) {
292
320
  const body = (await res.text()).slice(0, 300);
293
321
  throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}\n\n${AUTH_HELP}`, res.status);
package/dist/tools.js CHANGED
@@ -96,6 +96,31 @@ export function quotedInList(values) {
96
96
  .join(",");
97
97
  }
98
98
  export const FILES_CAP = 8;
99
+ /**
100
+ * Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
101
+ * paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
102
+ * regresión DISTINTOS (count(distinct plain), all-time), solo los con
103
+ * antecedentes (>= 2). Plain distinto, no filas, para que el re-levantado no
104
+ * infle. No se refuta: cuenta historia, no vigencia.
105
+ */
106
+ export function computeRecidivism(rows) {
107
+ const plainsByModule = new Map();
108
+ for (const r of rows) {
109
+ const m = (r.module ?? "").trim();
110
+ const p = (r.plain ?? "").trim();
111
+ if (!m || !p)
112
+ continue;
113
+ const set = plainsByModule.get(m) ?? new Set();
114
+ set.add(p);
115
+ plainsByModule.set(m, set);
116
+ }
117
+ const out = new Map();
118
+ for (const [m, set] of plainsByModule) {
119
+ if (set.size >= 2)
120
+ out.set(m, set.size);
121
+ }
122
+ return out;
123
+ }
99
124
  export function filesUnionByChange(rows, cap = FILES_CAP) {
100
125
  const acc = new Map();
101
126
  for (const r of rows) {
@@ -631,7 +656,7 @@ Args:
631
656
  - files (required): 1-8 repo-relative paths.
632
657
  - project (recommended): the repo you are working in (folder name or slug). The atlas is per-project — always scope to your own project.
633
658
 
634
- Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, recent_commits: [{ commit, commit_aliases, date }], recent_commits_more, watched_values: [{ name, value, commit }] }] }`,
659
+ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_changed, last_note }], open_alerts, recidivism: [{ module, prior_regressions }], recent_commits: [{ commit, commit_aliases, date }], recent_commits_more, watched_values: [{ name, value, commit }] }] }`,
635
660
  inputSchema: {
636
661
  files: z.array(z.string().min(1).max(300)).min(1).max(8)
637
662
  .describe("Repo-relative paths you are about to edit"),
@@ -710,7 +735,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
710
735
  for (const f of perFile) {
711
736
  commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
712
737
  }
713
- const [alerts, watched] = await Promise.all([
738
+ const [alerts, watched, recidivismRows] = await Promise.all([
714
739
  moduleNames.length
715
740
  ? db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
716
741
  pf)
@@ -723,6 +748,17 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
723
748
  .rest(`watched_values?select=file,name,value,commit_hash&file=in.(${encodeURIComponent(quotedInList(paths))})&order=name.asc&limit=40` +
724
749
  pf)
725
750
  .catch(() => []),
751
+ // Reincidencia (edit-time, espejo del hospedado): problemas de
752
+ // regresión de TODO el tiempo (sin filtro resolved) de los módulos
753
+ // tocados. Depende de moduleNames como las alertas → mismo Promise.all,
754
+ // sin ronda extra. Se cuenta distinct plain abajo (mismo criterio que
755
+ // el RPC del brief).
756
+ moduleNames.length
757
+ ? db
758
+ .rest(`regression_alerts?select=module,plain&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&limit=500` +
759
+ pf)
760
+ .catch(() => [])
761
+ : Promise.resolve([]),
726
762
  ]);
727
763
  const watchedByFile = new Map();
728
764
  for (const w of watched) {
@@ -747,6 +783,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
747
783
  continue;
748
784
  alertsByModule.set(m, [...(alertsByModule.get(m) ?? []), a.plain]);
749
785
  }
786
+ // Reincidencia: nº de problemas de regresión DISTINTOS por módulo
787
+ // (count(distinct plain), all-time), solo los con antecedentes (>= 2).
788
+ // Fuente única compartida (computeRecidivism) — antes 3 copias.
789
+ const recidivismByModule = computeRecidivism(recidivismRows);
750
790
  // Ancla temporal: el último commit analizado del proyecto vs el HEAD
751
791
  // de este árbol (misma puerta de proyecto que la refutación).
752
792
  // Best-effort: el ancla jamás rompe la lectura que ancla.
@@ -778,8 +818,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
778
818
  lines.push("No atlas history for this file yet (new or never analyzed).");
779
819
  }
780
820
  for (const m of f.modules) {
821
+ const previas = recidivismByModule.get(m.module);
781
822
  lines.push(`- Module **${m.module}** — ${m.changes} change(s), last ${m.last_changed}` +
782
- (m.risk ? `, risk: ${m.risk}` : ""));
823
+ (m.risk ? `, risk: ${m.risk}` : "") +
824
+ (previas ? ` · ⚠ ${previas} prior regressions` : ""));
783
825
  if (m.last_note) {
784
826
  // Con fecha: una nota es una observación fechada, no estado.
785
827
  lines.push(` - Note from last analysis (${m.last_changed}): ${m.last_note}`);
@@ -816,6 +858,12 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
816
858
  module: m.module,
817
859
  plain,
818
860
  }))),
861
+ recidivism: f.modules
862
+ .map((m) => ({
863
+ module: m.module,
864
+ prior_regressions: recidivismByModule.get(m.module) ?? 0,
865
+ }))
866
+ .filter((x) => x.prior_regressions >= 2),
819
867
  watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
820
868
  name: w.name,
821
869
  value: w.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changebook",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "mcpName": "io.github.raulbr90/changebook",
5
5
  "description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.raulbr90/changebook",
4
4
  "description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
5
- "version": "0.4.6",
5
+ "version": "0.4.7",
6
6
  "websiteUrl": "https://changebook.dev",
7
7
  "remotes": [
8
8
  {
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "changebook",
18
- "version": "0.4.6",
18
+ "version": "0.4.7",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  }