@lotargo/memory_plugin 1.4.0 → 1.4.5
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/mcp-server/admin/auth.js +385 -90
- package/mcp-server/cli.js +15 -16
- package/mcp-server/config/auth_store.js +40 -4
- package/mcp-server/config/config_manager.js +2 -0
- package/mcp-server/db/database.js +24 -7
- package/mcp-server/index.js +8 -6
- package/package.json +1 -1
package/mcp-server/admin/auth.js
CHANGED
|
@@ -1,90 +1,385 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { saveSecrets, deleteSecrets } from "../config/auth_store.js";
|
|
5
|
+
import { updateConfig } from "../config/config_manager.js";
|
|
6
|
+
|
|
7
|
+
export const TURSO_API_BASE = () => process.env.TURSO_API_BASE || "https://api.turso.tech";
|
|
8
|
+
|
|
9
|
+
function openBrowser(url) {
|
|
10
|
+
const platform = process.platform;
|
|
11
|
+
try {
|
|
12
|
+
if (platform === "win32") {
|
|
13
|
+
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
|
|
14
|
+
} else if (platform === "darwin") {
|
|
15
|
+
spawn("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
16
|
+
} else {
|
|
17
|
+
spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
18
|
+
}
|
|
19
|
+
} catch (err) {
|
|
20
|
+
// Browser auto-open is best-effort; the printed URL can be opened manually.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Starts a temporary loopback HTTP server to receive the OAuth callback.
|
|
25
|
+
// Turso redirects the browser back to the root path: /?jwt=<JWT>&username=<USERNAME>
|
|
26
|
+
export function startAuthLoopbackServer(port = 48900) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const server = http.createServer((req, res) => {
|
|
29
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
30
|
+
const token = url.searchParams.get("jwt") || url.searchParams.get("token");
|
|
31
|
+
const username = url.searchParams.get("username");
|
|
32
|
+
const error = url.searchParams.get("error");
|
|
33
|
+
|
|
34
|
+
if (token) {
|
|
35
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
36
|
+
res.end(`
|
|
37
|
+
<!DOCTYPE html>
|
|
38
|
+
<html lang="en">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8" />
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
42
|
+
<title>Authorization Successful</title>
|
|
43
|
+
<style>
|
|
44
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
45
|
+
body {
|
|
46
|
+
min-height: 100vh;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
51
|
+
-webkit-font-smoothing: antialiased;
|
|
52
|
+
background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
|
|
53
|
+
color: #e8ebf2;
|
|
54
|
+
padding: 24px;
|
|
55
|
+
}
|
|
56
|
+
.card {
|
|
57
|
+
max-width: 420px;
|
|
58
|
+
width: 100%;
|
|
59
|
+
background: #161a21;
|
|
60
|
+
border: 1px solid rgba(255, 255, 255, 0.07);
|
|
61
|
+
border-radius: 20px;
|
|
62
|
+
padding: 46px 38px;
|
|
63
|
+
text-align: center;
|
|
64
|
+
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
|
65
|
+
}
|
|
66
|
+
.badge {
|
|
67
|
+
width: 76px;
|
|
68
|
+
height: 76px;
|
|
69
|
+
margin: 0 auto 26px;
|
|
70
|
+
border-radius: 50%;
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
background: rgba(94, 224, 154, 0.10);
|
|
75
|
+
border: 1px solid rgba(94, 224, 154, 0.28);
|
|
76
|
+
}
|
|
77
|
+
.badge svg { width: 36px; height: 36px; }
|
|
78
|
+
h1 { font-size: 22px; font-weight: 600; letter-spacing: 0.2px; color: #f2f4f8; margin-bottom: 12px; }
|
|
79
|
+
p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
|
|
80
|
+
.hint { margin-top: 24px; font-size: 12.5px; color: #6f7887; }
|
|
81
|
+
</style>
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
<div class="card">
|
|
85
|
+
<div class="badge">
|
|
86
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="#5ee09a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
87
|
+
<path d="M20 6 9 17l-5-5" />
|
|
88
|
+
</svg>
|
|
89
|
+
</div>
|
|
90
|
+
<h1>Authorization successful</h1>
|
|
91
|
+
<p>Your credentials were received and stored securely on this device.</p>
|
|
92
|
+
<div class="hint">You can now close this tab and return to the terminal.</div>
|
|
93
|
+
</div>
|
|
94
|
+
</body>
|
|
95
|
+
</html>
|
|
96
|
+
`);
|
|
97
|
+
|
|
98
|
+
server.close(() => {
|
|
99
|
+
resolve({ token, username: username || "" });
|
|
100
|
+
});
|
|
101
|
+
} else if (error) {
|
|
102
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
103
|
+
res.end(`
|
|
104
|
+
<!DOCTYPE html>
|
|
105
|
+
<html lang="en">
|
|
106
|
+
<head>
|
|
107
|
+
<meta charset="utf-8" />
|
|
108
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
109
|
+
<title>Authorization Failed</title>
|
|
110
|
+
<style>
|
|
111
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
112
|
+
body {
|
|
113
|
+
min-height: 100vh;
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
118
|
+
-webkit-font-smoothing: antialiased;
|
|
119
|
+
background: radial-gradient(1200px 600px at 50% -10%, #1c2028 0%, #101218 55%, #0c0e13 100%);
|
|
120
|
+
color: #e8ebf2;
|
|
121
|
+
padding: 24px;
|
|
122
|
+
}
|
|
123
|
+
.card {
|
|
124
|
+
max-width: 400px;
|
|
125
|
+
width: 100%;
|
|
126
|
+
background: #161a21;
|
|
127
|
+
border: 1px solid rgba(255, 255, 255, 0.07);
|
|
128
|
+
border-radius: 20px;
|
|
129
|
+
padding: 40px 34px;
|
|
130
|
+
text-align: center;
|
|
131
|
+
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
|
132
|
+
}
|
|
133
|
+
h1 { font-size: 20px; font-weight: 600; color: #f2f4f8; margin-bottom: 12px; }
|
|
134
|
+
p { font-size: 14px; line-height: 1.65; color: #9aa3b2; }
|
|
135
|
+
</style>
|
|
136
|
+
</head>
|
|
137
|
+
<body>
|
|
138
|
+
<div class="card">
|
|
139
|
+
<h1>Authorization failed</h1>
|
|
140
|
+
<p>An error occurred during the login flow. Close this tab, return to the terminal, and try again.</p>
|
|
141
|
+
</div>
|
|
142
|
+
</body>
|
|
143
|
+
</html>
|
|
144
|
+
`);
|
|
145
|
+
server.close(() => reject(new Error(`Authentication error: ${error}`)));
|
|
146
|
+
} else {
|
|
147
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
148
|
+
res.end("Not Found");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
server.on("error", (err) => {
|
|
153
|
+
reject(err);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
server.listen(port, "127.0.0.1", () => {
|
|
157
|
+
console.log(`\n [*] Waiting for authorization on local port http://localhost:${port}/...`);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function apiRequest(token, pathname, { method = "GET", body } = {}) {
|
|
163
|
+
const res = await fetch(`${TURSO_API_BASE()}${pathname}`, {
|
|
164
|
+
method,
|
|
165
|
+
headers: {
|
|
166
|
+
Authorization: `Bearer ${token}`,
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
Accept: "application/json",
|
|
169
|
+
},
|
|
170
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const text = await res.text();
|
|
174
|
+
let data = null;
|
|
175
|
+
try {
|
|
176
|
+
data = JSON.parse(text);
|
|
177
|
+
} catch {}
|
|
178
|
+
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const err = new Error(data?.error || `Turso API ${res.status}: ${text}`);
|
|
181
|
+
err.status = res.status;
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Validate the account JWT obtained from OAuth and return current-user info.
|
|
188
|
+
export async function validateTursoToken(token) {
|
|
189
|
+
return apiRequest(token, "/v1/current-user");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function listOrganizations(token) {
|
|
193
|
+
const data = await apiRequest(token, "/v1/organizations");
|
|
194
|
+
const orgs = data?.organizations || [];
|
|
195
|
+
return orgs.map((o) => ({
|
|
196
|
+
slug: o.slug || o.Slug || o.id || o.Id || null,
|
|
197
|
+
name: o.name || o.Name || null,
|
|
198
|
+
id: o.id || o.Id || null,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function listDatabases(token, org) {
|
|
203
|
+
const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`);
|
|
204
|
+
const dbs = data?.databases || [];
|
|
205
|
+
return dbs.map((d) => ({
|
|
206
|
+
name: d.name || d.Name,
|
|
207
|
+
hostname: d.hostname || d.Hostname,
|
|
208
|
+
id: d.id || d.Id,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function createDatabase(token, org, name) {
|
|
213
|
+
try {
|
|
214
|
+
const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
body: { name },
|
|
217
|
+
});
|
|
218
|
+
const d = data?.database || data;
|
|
219
|
+
return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// Fresh accounts have no default group; create one, then retry.
|
|
222
|
+
if (!String(err.message || "").toLowerCase().includes("group")) {
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
console.log(` [CLOUD] No group found. Creating group "default"...`);
|
|
226
|
+
await createGroup(token, org, "default");
|
|
227
|
+
const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/databases`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
body: { name, group: "default" },
|
|
230
|
+
});
|
|
231
|
+
const d = data?.database || data;
|
|
232
|
+
return { name: d.name || d.Name, hostname: d.hostname || d.Hostname, id: d.id || d.Id };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Turso's public closest-region endpoint (no auth required).
|
|
237
|
+
// Returns e.g. { server: "aws-eu-west-1", client: "ams" }.
|
|
238
|
+
async function getClosestLocation() {
|
|
239
|
+
if (process.env.TURSO_LOCATION) return process.env.TURSO_LOCATION;
|
|
240
|
+
const fallback = "ams";
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetch("https://region.turso.io/", { signal: AbortSignal.timeout(8000) });
|
|
243
|
+
const data = await res.json().catch(() => null);
|
|
244
|
+
const loc = data?.server || data?.client || null;
|
|
245
|
+
if (loc && /^[a-z0-9-]+$/i.test(loc)) return loc;
|
|
246
|
+
} catch {
|
|
247
|
+
// ignore
|
|
248
|
+
}
|
|
249
|
+
return fallback;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function createGroup(token, org, name) {
|
|
253
|
+
// Always provide an explicit location: Turso's internal auto-lookup fails
|
|
254
|
+
// with "invalid location: Host not found" when the group has no location.
|
|
255
|
+
const location = await getClosestLocation();
|
|
256
|
+
const data = await apiRequest(token, `/v1/organizations/${encodeURIComponent(org)}/groups`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
body: { name, location },
|
|
259
|
+
});
|
|
260
|
+
return data?.group || data;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function createDatabaseToken(token, org, db, { expiration = "never", authorization = "full-access" } = {}) {
|
|
264
|
+
const data = await apiRequest(
|
|
265
|
+
token,
|
|
266
|
+
`/v1/organizations/${encodeURIComponent(org)}/databases/${encodeURIComponent(db)}/auth/tokens?expiration=${encodeURIComponent(expiration)}&authorization=${encodeURIComponent(authorization)}`,
|
|
267
|
+
{ method: "POST", body: {} }
|
|
268
|
+
);
|
|
269
|
+
return data?.jwt || null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function dbHostname(org, dbName) {
|
|
273
|
+
return `${dbName}-${org}.turso.io`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Perform the full cloud login flow:
|
|
277
|
+
// 1. OAuth browser flow against Turso (api.turso.tech).
|
|
278
|
+
// 2. Validate the received account JWT.
|
|
279
|
+
// 3. Resolve an organization and pick/create a database.
|
|
280
|
+
// 4. Mint a full-access token for that database.
|
|
281
|
+
// 5. Persist the encrypted token + dbUrl and mark the session as authorized.
|
|
282
|
+
export async function loginToCloud({
|
|
283
|
+
customPort = 48900,
|
|
284
|
+
simulated = false,
|
|
285
|
+
simulatedParams = null,
|
|
286
|
+
autoCreate = true,
|
|
287
|
+
databaseName = null,
|
|
288
|
+
} = {}) {
|
|
289
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
290
|
+
const loginUrl = `${TURSO_API_BASE()}/?port=${customPort}&redirect=true&state=${state}&type=cli`;
|
|
291
|
+
|
|
292
|
+
console.log(`\n [CLOUD] Please open your system browser to authorize:`);
|
|
293
|
+
console.log(` \x1b[36m${loginUrl}\x1b[0m\n`);
|
|
294
|
+
|
|
295
|
+
let received;
|
|
296
|
+
if (simulated && simulatedParams) {
|
|
297
|
+
received = await new Promise((resolve, reject) => {
|
|
298
|
+
const serverPromise = startAuthLoopbackServer(customPort);
|
|
299
|
+
const req = http.request(
|
|
300
|
+
`http://127.0.0.1:${customPort}/?jwt=${encodeURIComponent(simulatedParams.jwt)}&username=${encodeURIComponent(simulatedParams.username)}`,
|
|
301
|
+
{ method: "GET" },
|
|
302
|
+
(res) => {
|
|
303
|
+
res.resume();
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
req.on("error", (e) => reject(e));
|
|
307
|
+
req.end();
|
|
308
|
+
serverPromise.then(resolve).catch(reject);
|
|
309
|
+
});
|
|
310
|
+
} else {
|
|
311
|
+
openBrowser(loginUrl);
|
|
312
|
+
received = await startAuthLoopbackServer(customPort);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const { token, username } = received;
|
|
316
|
+
|
|
317
|
+
// Step 2: validate the account token
|
|
318
|
+
let userInfo = null;
|
|
319
|
+
try {
|
|
320
|
+
userInfo = await validateTursoToken(token);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
throw new Error(`Token validation failed: ${err.message}`);
|
|
323
|
+
}
|
|
324
|
+
const accountUsername = username || userInfo?.username || userInfo?.name || "user";
|
|
325
|
+
console.log(` [OK] Token is valid. User: ${accountUsername}`);
|
|
326
|
+
|
|
327
|
+
// Step 3: resolve organization + database
|
|
328
|
+
const orgs = await listOrganizations(token);
|
|
329
|
+
let org;
|
|
330
|
+
let orgName;
|
|
331
|
+
if (orgs && orgs.length > 0) {
|
|
332
|
+
org = orgs[0].slug || orgs[0].name || orgs[0].id || String(orgs[0]);
|
|
333
|
+
orgName = orgs[0].name || org;
|
|
334
|
+
} else {
|
|
335
|
+
// Personal accounts are not listed in /v1/organizations, but their own
|
|
336
|
+
// username acts as the organization namespace in the Platform API.
|
|
337
|
+
org = accountUsername;
|
|
338
|
+
orgName = accountUsername;
|
|
339
|
+
console.log(` [CLOUD] No organizations found. Using personal account "${org}" as the database namespace.`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const dbs = await listDatabases(token, org);
|
|
343
|
+
if (dbs.length > 0) {
|
|
344
|
+
console.log(`\n [CLOUD] Databases in organization "${orgName}":`);
|
|
345
|
+
dbs.forEach((d, i) => console.log(` ${i + 1}. ${d.name}`));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let dbName = databaseName;
|
|
349
|
+
if (!dbName) {
|
|
350
|
+
if (dbs.length > 0) {
|
|
351
|
+
dbName = dbs[0].name;
|
|
352
|
+
console.log(`\n [CLOUD] Using existing database: "${dbName}"`);
|
|
353
|
+
} else if (autoCreate) {
|
|
354
|
+
dbName = `memory-${accountUsername}`;
|
|
355
|
+
console.log(`\n [CLOUD] No database found. Creating "${dbName}"...`);
|
|
356
|
+
await createDatabase(token, org, dbName);
|
|
357
|
+
console.log(` [OK] Database "${dbName}" created.`);
|
|
358
|
+
} else {
|
|
359
|
+
throw new Error("No databases found and autoCreate is disabled.");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Step 4: mint a full-access token for the database
|
|
364
|
+
console.log(" [CLOUD] Issuing database access token...");
|
|
365
|
+
const dbJwt = await createDatabaseToken(token, org, dbName);
|
|
366
|
+
if (!dbJwt) {
|
|
367
|
+
throw new Error("Failed to create database auth token.");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const dbUrl = `libsql://${dbHostname(org, dbName)}`;
|
|
371
|
+
|
|
372
|
+
// Step 5: persist secrets and mark authorized
|
|
373
|
+
saveSecrets({ token: dbJwt, dbUrl, username: accountUsername, org, db: dbName, authorized: true });
|
|
374
|
+
updateConfig({ tursoUrl: dbUrl, authorized: true, username: accountUsername });
|
|
375
|
+
|
|
376
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Endpoint: ${dbUrl}\x1b[0m`);
|
|
377
|
+
return { token: dbJwt, dbUrl, username: accountUsername, org, db: dbName, authorized: true };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Logout and reset configurations
|
|
381
|
+
export function logoutFromCloud() {
|
|
382
|
+
const deleted = deleteSecrets();
|
|
383
|
+
updateConfig({ tursoUrl: "", mode: "only-local", authorized: false, username: "" });
|
|
384
|
+
return deleted;
|
|
385
|
+
}
|
package/mcp-server/cli.js
CHANGED
|
@@ -660,28 +660,27 @@ export async function runCli() {
|
|
|
660
660
|
return;
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
-
const cliArgs = process.argv.slice(2);
|
|
664
663
|
if (cliArgs.includes("login")) {
|
|
665
|
-
console.log("\n [CLOUD]
|
|
664
|
+
console.log("\n [CLOUD] Starting Turso cloud authorization...");
|
|
666
665
|
const { loginToCloud } = await import("./admin/auth.js");
|
|
667
666
|
try {
|
|
668
667
|
const secrets = await loginToCloud();
|
|
669
|
-
console.log(`\n \x1b[32m[OK]
|
|
668
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
|
|
670
669
|
} catch (e) {
|
|
671
|
-
console.error(`\n \x1b[31m[ERROR]
|
|
670
|
+
console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
|
|
672
671
|
process.exit(1);
|
|
673
672
|
}
|
|
674
673
|
return;
|
|
675
674
|
}
|
|
676
675
|
|
|
677
676
|
if (cliArgs.includes("logout")) {
|
|
678
|
-
console.log("\n [CLOUD]
|
|
677
|
+
console.log("\n [CLOUD] Signing out of the cloud...");
|
|
679
678
|
const { logoutFromCloud } = await import("./admin/auth.js");
|
|
680
679
|
const deleted = logoutFromCloud();
|
|
681
680
|
if (deleted) {
|
|
682
|
-
console.log(" \x1b[32m[OK]
|
|
681
|
+
console.log(" \x1b[32m[OK] You have been signed out. Encrypted secrets removed. Mode reverted to only-local.\x1b[0m\n");
|
|
683
682
|
} else {
|
|
684
|
-
console.log(" [*]
|
|
683
|
+
console.log(" [*] Mode reverted to only-local. No session tokens were found.\x1b[0m\n");
|
|
685
684
|
}
|
|
686
685
|
return;
|
|
687
686
|
}
|
|
@@ -1830,35 +1829,35 @@ export async function runCli() {
|
|
|
1830
1829
|
}
|
|
1831
1830
|
case "cloud_login": {
|
|
1832
1831
|
console.clear();
|
|
1833
|
-
console.log("\n [CLOUD]
|
|
1832
|
+
console.log("\n [CLOUD] Starting Turso cloud authorization...");
|
|
1834
1833
|
const { loginToCloud } = await import("./admin/auth.js");
|
|
1835
1834
|
try {
|
|
1836
1835
|
const secrets = await loginToCloud();
|
|
1837
|
-
console.log(`\n \x1b[32m[OK]
|
|
1836
|
+
console.log(`\n \x1b[32m[OK] Successfully signed in to the cloud! Connected to endpoint: ${secrets.dbUrl}\x1b[0m\n`);
|
|
1838
1837
|
} catch (e) {
|
|
1839
|
-
console.error(`\n \x1b[31m[ERROR]
|
|
1838
|
+
console.error(`\n \x1b[31m[ERROR] Authorization failed: ${e.message}\x1b[0m\n`);
|
|
1840
1839
|
}
|
|
1841
1840
|
await waitForEnter();
|
|
1842
1841
|
break;
|
|
1843
1842
|
}
|
|
1844
1843
|
case "cloud_logout": {
|
|
1845
1844
|
console.clear();
|
|
1846
|
-
console.log("\n [CLOUD]
|
|
1845
|
+
console.log("\n [CLOUD] Signing out of the cloud...");
|
|
1847
1846
|
const { logoutFromCloud } = await import("./admin/auth.js");
|
|
1848
1847
|
const deleted = logoutFromCloud();
|
|
1849
1848
|
if (deleted) {
|
|
1850
|
-
console.log(" \x1b[32m[OK]
|
|
1849
|
+
console.log(" \x1b[32m[OK] You have been signed out. Encrypted secrets removed. Mode reverted to only-local.\x1b[0m\n");
|
|
1851
1850
|
} else {
|
|
1852
|
-
console.log(" [*]
|
|
1851
|
+
console.log(" [*] Mode reverted to only-local. No session tokens were found.\x1b[0m\n");
|
|
1853
1852
|
}
|
|
1854
1853
|
await waitForEnter();
|
|
1855
1854
|
break;
|
|
1856
1855
|
}
|
|
1857
1856
|
case "cloud_mode": {
|
|
1858
1857
|
const modeItems = [
|
|
1859
|
-
{ label: "only-local (
|
|
1860
|
-
{ label: "only-cloud (
|
|
1861
|
-
{ label: "hybrid-sync (
|
|
1858
|
+
{ label: "only-local (Local only)", value: "only-local", info: "Fully private, offline-first mode (everything stored on disk)" },
|
|
1859
|
+
{ label: "only-cloud (Cloud only)", value: "only-cloud", info: "Fully serverless cloud mode with no local caching" },
|
|
1860
|
+
{ label: "hybrid-sync (Local with background sync)", value: "hybrid-sync", info: "Instant local operations with a background sync daemon" },
|
|
1862
1861
|
];
|
|
1863
1862
|
const initialIdx = Math.max(0, modeItems.findIndex((i) => i.value === config.mode));
|
|
1864
1863
|
const subRes = await selectSimpleMenu({
|
|
@@ -2,19 +2,51 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import os from "node:os";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
5
6
|
import { MEMORY_DIR, ensureDirSync } from "../memory.js";
|
|
6
7
|
|
|
7
8
|
const SECRETS_FILE = path.join(MEMORY_DIR, "auth_secrets.enc");
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
+
// Stable per-machine identifier. Must NOT rely on volatile values (e.g.
|
|
11
|
+
// os.networkInterfaces() — VPN adapters, hotspot IPs and IPv6 privacy
|
|
12
|
+
// addresses rotate constantly and would silently change the AES key).
|
|
13
|
+
function getMachineId() {
|
|
14
|
+
try {
|
|
15
|
+
if (process.platform === "win32") {
|
|
16
|
+
const out = execSync("reg query HKLM\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid", {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
+
});
|
|
20
|
+
const m = out.match(/MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{36})/i);
|
|
21
|
+
if (m) return m[1].toLowerCase();
|
|
22
|
+
} else if (process.platform === "linux") {
|
|
23
|
+
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
24
|
+
try {
|
|
25
|
+
const v = fs.readFileSync(p, "utf8").trim();
|
|
26
|
+
if (v) return v;
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
} else if (process.platform === "darwin") {
|
|
30
|
+
const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
});
|
|
34
|
+
const m = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
|
|
35
|
+
if (m) return m[1];
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generate a deterministic hardware + system fingerprint (stable across reboots,
|
|
42
|
+
// network changes and user sessions on the same machine).
|
|
10
43
|
function getSystemFingerprint() {
|
|
11
44
|
const parts = [
|
|
45
|
+
getMachineId() || "no-machine-id",
|
|
12
46
|
os.hostname() || "localhost",
|
|
13
47
|
os.userInfo()?.username || "default_user",
|
|
14
48
|
os.platform() || "unknown",
|
|
15
49
|
os.arch() || "unknown",
|
|
16
|
-
// Fallback if network interfaces list is empty or can't be fetched
|
|
17
|
-
JSON.stringify(os.networkInterfaces() || {}),
|
|
18
50
|
];
|
|
19
51
|
return parts.join("|");
|
|
20
52
|
}
|
|
@@ -82,7 +114,11 @@ export function loadSecrets() {
|
|
|
82
114
|
const decrypted = decryptData(encrypted);
|
|
83
115
|
return JSON.parse(decrypted);
|
|
84
116
|
} catch (err) {
|
|
85
|
-
console.error(
|
|
117
|
+
console.error(
|
|
118
|
+
"Failed to decrypt or load cloud secrets:",
|
|
119
|
+
err.message,
|
|
120
|
+
"— the file was encrypted with a different machine key. Re-run login to recreate it."
|
|
121
|
+
);
|
|
86
122
|
return null;
|
|
87
123
|
}
|
|
88
124
|
}
|
|
@@ -17,6 +17,8 @@ export const DEFAULT_CONFIG = {
|
|
|
17
17
|
mode: "only-local", // "only-local" | "only-cloud" | "hybrid-sync"
|
|
18
18
|
tursoUrl: "", // Connection endpoint URL for Turso DB
|
|
19
19
|
failoverUrl: "", // Failover connection endpoint URL (Fly.io + LiteFS)
|
|
20
|
+
authorized: false, // True once the user completed cloud login (token stored encrypted)
|
|
21
|
+
username: "", // Account username from the Turso OAuth profile
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
let cachedConfig = null;
|
|
@@ -8,6 +8,7 @@ import { loadSecrets } from "../config/auth_store.js";
|
|
|
8
8
|
import { createClient } from "@libsql/client";
|
|
9
9
|
|
|
10
10
|
let dbInstance = null;
|
|
11
|
+
let dbInitPromise = null;
|
|
11
12
|
|
|
12
13
|
export const STORAGE_DIR = join(MEMORY_DIR, "storage");
|
|
13
14
|
export const BLOBS_DIR = join(STORAGE_DIR, "blobs");
|
|
@@ -141,14 +142,8 @@ class DatabaseWrapper {
|
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
async function openDatabase(customPath, mode) {
|
|
145
146
|
const config = getConfig();
|
|
146
|
-
const mode = forceMode || config.mode || "only-local";
|
|
147
|
-
|
|
148
|
-
if (dbInstance && !customPath && dbInstance.mode === mode) {
|
|
149
|
-
return dbInstance;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
147
|
let localDb = null;
|
|
153
148
|
if (mode !== "only-cloud") {
|
|
154
149
|
const dbPath = customPath || DB_PATH;
|
|
@@ -202,7 +197,29 @@ export async function getDatabase(customPath = null, forceMode = null) {
|
|
|
202
197
|
return wrappedDb;
|
|
203
198
|
}
|
|
204
199
|
|
|
200
|
+
export async function getDatabase(customPath = null, forceMode = null) {
|
|
201
|
+
const config = getConfig();
|
|
202
|
+
const mode = forceMode || config.mode || "only-local";
|
|
203
|
+
|
|
204
|
+
if (!customPath) {
|
|
205
|
+
if (dbInstance && dbInstance.mode === mode) {
|
|
206
|
+
return dbInstance;
|
|
207
|
+
}
|
|
208
|
+
// Deduplicate concurrent default-DB initialization so migrations never run
|
|
209
|
+
// on multiple connections at once (avoids "database is locked" crashes).
|
|
210
|
+
if (!dbInitPromise) {
|
|
211
|
+
dbInitPromise = openDatabase(null, mode).finally(() => {
|
|
212
|
+
dbInitPromise = null;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return await dbInitPromise;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return openDatabase(customPath, mode);
|
|
219
|
+
}
|
|
220
|
+
|
|
205
221
|
export function closeDatabase() {
|
|
222
|
+
dbInitPromise = null;
|
|
206
223
|
if (dbInstance) {
|
|
207
224
|
dbInstance.close();
|
|
208
225
|
dbInstance = null;
|
package/mcp-server/index.js
CHANGED
|
@@ -175,10 +175,10 @@ server.registerTool(
|
|
|
175
175
|
const { getLinksForFact } = await import("./graph/knowledge_linker.js");
|
|
176
176
|
const results = [];
|
|
177
177
|
|
|
178
|
-
const formatFactWithLinks = (factLine, key) => {
|
|
178
|
+
const formatFactWithLinks = async (factLine, key) => {
|
|
179
179
|
let line = displayFact(factLine);
|
|
180
180
|
try {
|
|
181
|
-
const links = getLinksForFact(key, factText(factLine));
|
|
181
|
+
const links = await getLinksForFact(key, factText(factLine));
|
|
182
182
|
if (links && links.length > 0) {
|
|
183
183
|
const docStr = links
|
|
184
184
|
.map((l) => {
|
|
@@ -192,14 +192,16 @@ server.registerTool(
|
|
|
192
192
|
return line;
|
|
193
193
|
};
|
|
194
194
|
|
|
195
|
-
const collect = (entries, key) => {
|
|
195
|
+
const collect = async (entries, key) => {
|
|
196
196
|
const matched = entries.filter(
|
|
197
197
|
(e) => matchesQuery(e, query) && matchesTags(e, tags) && inDateRange(e, since, until)
|
|
198
198
|
);
|
|
199
199
|
if (!matched.length) return;
|
|
200
200
|
if (results.length) results.push("");
|
|
201
201
|
results.push(`--- ${key === GLOBAL_KEY ? "Global" : `Project: ${key === target ? label : key}`} ---`);
|
|
202
|
-
|
|
202
|
+
for (let i = 0; i < matched.length; i++) {
|
|
203
|
+
results.push(`${i + 1}. ${await formatFactWithLinks(matched[i], key)}`);
|
|
204
|
+
}
|
|
203
205
|
results.push(`Store file: ${storeFilePath(key)}`);
|
|
204
206
|
};
|
|
205
207
|
|
|
@@ -225,11 +227,11 @@ server.registerTool(
|
|
|
225
227
|
const label = project ? target : projectName();
|
|
226
228
|
if (scope !== "project") {
|
|
227
229
|
const global = await readMemory(GLOBAL_KEY);
|
|
228
|
-
collect(global, GLOBAL_KEY);
|
|
230
|
+
await collect(global, GLOBAL_KEY);
|
|
229
231
|
}
|
|
230
232
|
if (scope !== "global") {
|
|
231
233
|
const local = await readMemory(target);
|
|
232
|
-
collect(local, target);
|
|
234
|
+
await collect(local, target);
|
|
233
235
|
}
|
|
234
236
|
const filtered = Boolean(query || tags || since || until);
|
|
235
237
|
const text = results.length
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotargo/memory_plugin",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
4
4
|
"description": "100% local hybrid RAG memory for AI coding agents (OpenCode, Claude Code, Codex, Antigravity). MCP server + plugin: persistent user facts, document ingestion, vector + SQLite FTS5 retrieval across sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "opencode-plugin/index.js",
|