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.
- package/dist/credentials.js +71 -0
- package/dist/supabase.js +50 -22
- package/dist/tools.js +51 -3
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/credentials.js
CHANGED
|
@@ -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
|
-
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
18
|
+
"version": "0.4.7",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|