@cybermem/dashboard 0.13.16 → 0.14.4
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/CHANGELOG.md +12 -0
- package/app/api/backup/route.ts +37 -33
- package/app/api/mcp-config/route.ts +33 -21
- package/app/api/reset/route.ts +3 -3
- package/app/api/restore/route.ts +38 -34
- package/app/api/settings/route.ts +57 -3
- package/components/dashboard/settings/access-token-section.tsx +6 -1
- package/components/dashboard/settings-modal.tsx +14 -0
- package/e2e/api.spec.ts +23 -0
- package/e2e/reset-db.spec.ts +69 -0
- package/e2e/ui.spec.ts +12 -9
- package/eslint.config.mjs +6 -3
- package/lib/resolve-data-dir.ts +17 -0
- package/package.json +1 -1
- package/public/clients.json +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @cybermem/dashboard
|
|
2
|
+
|
|
3
|
+
## 0.14.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#86](https://github.com/mikhailkogan17/cybermem/pull/86) [`ffca5dd`](https://github.com/mikhailkogan17/cybermem/commit/ffca5dd374a50f594c1a935f1fe81ee1e1e3c6fe) Thanks [@copilot-swe-agent](https://github.com/apps/copilot-swe-agent)! - Migrate version management to Changesets
|
|
8
|
+
|
|
9
|
+
- Remove custom bash scripts (version-bump.sh, release.sh)
|
|
10
|
+
- Add Changesets for automated versioning and changelog generation
|
|
11
|
+
- Update publish workflow to use Changesets
|
|
12
|
+
- Add documentation for new release process
|
package/app/api/backup/route.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { resolveDataDir } from "@/lib/resolve-data-dir";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { createReadStream, rmSync, statSync } from "fs";
|
|
4
|
+
import { NextResponse } from "next/server";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
6
7
|
|
|
7
|
-
export const dynamic =
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
8
9
|
|
|
9
|
-
const DATA_DIR =
|
|
10
|
+
const DATA_DIR = resolveDataDir();
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* GET /api/backup
|
|
@@ -14,52 +15,55 @@ const DATA_DIR = process.env.DATA_DIR || '/data'
|
|
|
14
15
|
*/
|
|
15
16
|
export async function GET() {
|
|
16
17
|
try {
|
|
17
|
-
const timestamp = new Date()
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const timestamp = new Date()
|
|
19
|
+
.toISOString()
|
|
20
|
+
.replace(/[:.]/g, "-")
|
|
21
|
+
.slice(0, 19);
|
|
22
|
+
const backupName = `cybermem-backup-${timestamp}.tar.gz`;
|
|
23
|
+
const tmpPath = join(tmpdir(), backupName);
|
|
20
24
|
|
|
21
25
|
try {
|
|
22
26
|
// Create backup via tar command (available in Node alpine image)
|
|
23
|
-
execSync(`tar -czf "${tmpPath}" -C "${DATA_DIR}" .`, { stdio:
|
|
27
|
+
execSync(`tar -czf "${tmpPath}" -C "${DATA_DIR}" .`, { stdio: "pipe" });
|
|
24
28
|
|
|
25
29
|
// Get file stats
|
|
26
|
-
const stats = statSync(tmpPath)
|
|
30
|
+
const stats = statSync(tmpPath);
|
|
27
31
|
|
|
28
32
|
// Stream the file
|
|
29
|
-
const stream = createReadStream(tmpPath)
|
|
33
|
+
const stream = createReadStream(tmpPath);
|
|
30
34
|
|
|
31
35
|
// Convert Node stream to Web ReadableStream
|
|
32
36
|
const webStream = new ReadableStream({
|
|
33
37
|
start(controller) {
|
|
34
|
-
stream.on(
|
|
35
|
-
stream.on(
|
|
36
|
-
controller.close()
|
|
38
|
+
stream.on("data", (chunk) => controller.enqueue(chunk));
|
|
39
|
+
stream.on("end", () => {
|
|
40
|
+
controller.close();
|
|
37
41
|
// Clean up temp file after streaming
|
|
38
|
-
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
try {
|
|
43
|
+
rmSync(tmpPath);
|
|
44
|
+
} catch {}
|
|
45
|
+
});
|
|
46
|
+
stream.on("error", (err) => controller.error(err));
|
|
47
|
+
},
|
|
48
|
+
});
|
|
43
49
|
|
|
44
50
|
return new NextResponse(webStream, {
|
|
45
51
|
headers: {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
+
"Content-Type": "application/gzip",
|
|
53
|
+
"Content-Disposition": `attachment; filename="${backupName}"`,
|
|
54
|
+
"Content-Length": String(stats.size),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
52
57
|
} catch (tarError: any) {
|
|
53
58
|
return NextResponse.json(
|
|
54
59
|
{ error: `Backup failed: ${tarError.message}` },
|
|
55
|
-
{ status: 500 }
|
|
56
|
-
)
|
|
60
|
+
{ status: 500 },
|
|
61
|
+
);
|
|
57
62
|
}
|
|
58
|
-
|
|
59
63
|
} catch (error: any) {
|
|
60
64
|
return NextResponse.json(
|
|
61
|
-
{ error: error.message ||
|
|
62
|
-
{ status: 500 }
|
|
63
|
-
)
|
|
65
|
+
{ error: error.message || "Unknown error" },
|
|
66
|
+
{ status: 500 },
|
|
67
|
+
);
|
|
64
68
|
}
|
|
65
69
|
}
|
|
@@ -67,37 +67,49 @@ export async function GET(request: Request) {
|
|
|
67
67
|
let config: any;
|
|
68
68
|
let configType = client?.configType || "json";
|
|
69
69
|
|
|
70
|
+
// Local envs (isManaged): use @cybermem/mcp directly
|
|
71
|
+
// Remote envs: use mcp-remote (standard stdio-to-HTTP bridge)
|
|
70
72
|
if (configType === "toml") {
|
|
71
73
|
if (isManaged) {
|
|
72
|
-
|
|
74
|
+
const localArgs = isStaging
|
|
75
|
+
? `["-y", "@cybermem/mcp", "--env", "staging"]`
|
|
76
|
+
: `["-y", "@cybermem/mcp"]`;
|
|
77
|
+
config = `[mcpServers.cybermem]\ncommand = "npx"\nargs = ${localArgs}`;
|
|
73
78
|
} else {
|
|
74
79
|
const keyVal = maskKey ? displayKey : actualKey;
|
|
75
|
-
config = `[mcpServers.cybermem]\ncommand = "npx"\nargs = ["
|
|
80
|
+
config = `[mcpServers.cybermem]\ncommand = "npx"\nargs = ["-y", "mcp-remote", "${baseUrl}", "--header", "X-API-Key:${keyVal}"]`;
|
|
76
81
|
}
|
|
77
82
|
} else if (configType === "command" || configType === "cmd") {
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
83
|
+
// Generate command directly (don't rely on clients.json templates)
|
|
84
|
+
if (isManaged) {
|
|
85
|
+
const clientName = client?.id || "cybermem";
|
|
86
|
+
const cliPrefix = client?.id === "gemini-cli" ? "gemini" : "claude";
|
|
87
|
+
config = `${cliPrefix} mcp add ${clientName} npx -y @cybermem/mcp`;
|
|
88
|
+
} else {
|
|
89
|
+
const keyVal = maskKey ? displayKey : actualKey;
|
|
90
|
+
const clientName = client?.id || "cybermem";
|
|
91
|
+
const cliPrefix = client?.id === "gemini-cli" ? "gemini" : "claude";
|
|
92
|
+
config = `${cliPrefix} mcp add ${clientName} -- npx -y mcp-remote ${baseUrl} --header X-API-Key:${keyVal}`;
|
|
81
93
|
}
|
|
82
|
-
cmd = cmd.replace("{{ENDPOINT}}", baseUrl);
|
|
83
|
-
cmd = cmd.replace("{{API_KEY}}", maskKey ? displayKey : actualKey);
|
|
84
|
-
cmd = cmd.replace("{{TOKEN}}", maskKey ? displayKey : actualKey);
|
|
85
|
-
config = cmd;
|
|
86
94
|
} else {
|
|
87
95
|
// JSON (default)
|
|
88
|
-
|
|
89
|
-
? ["-y", "@cybermem/mcp"]
|
|
90
|
-
: [
|
|
91
|
-
"-y",
|
|
92
|
-
"@cybermem/mcp",
|
|
93
|
-
"--url",
|
|
94
|
-
baseUrl,
|
|
95
|
-
"--token",
|
|
96
|
-
maskKey ? displayKey : actualKey,
|
|
97
|
-
];
|
|
96
|
+
let args: string[];
|
|
98
97
|
|
|
99
|
-
if (
|
|
100
|
-
|
|
98
|
+
if (isManaged) {
|
|
99
|
+
// Local: use @cybermem/mcp directly (SDK mode)
|
|
100
|
+
args = ["-y", "@cybermem/mcp"];
|
|
101
|
+
if (isStaging) {
|
|
102
|
+
args.push("--env", "staging");
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// Remote: use mcp-remote (standard stdio-to-HTTP bridge)
|
|
106
|
+
args = [
|
|
107
|
+
"-y",
|
|
108
|
+
"mcp-remote",
|
|
109
|
+
baseUrl,
|
|
110
|
+
"--header",
|
|
111
|
+
`X-API-Key:${maskKey ? displayKey : actualKey}`,
|
|
112
|
+
];
|
|
101
113
|
}
|
|
102
114
|
|
|
103
115
|
config = {
|
package/app/api/reset/route.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { resolveDataDir } from "@/lib/resolve-data-dir";
|
|
1
2
|
import { readdirSync, statSync, unlinkSync } from "fs";
|
|
2
3
|
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
-
import {
|
|
4
|
-
import { join, resolve } from "path";
|
|
4
|
+
import { join } from "path";
|
|
5
5
|
|
|
6
6
|
export const dynamic = "force-dynamic";
|
|
7
7
|
|
|
8
|
-
const DATA_DIR =
|
|
8
|
+
const DATA_DIR = resolveDataDir();
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* POST /api/reset
|
package/app/api/restore/route.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { resolveDataDir } from "@/lib/resolve-data-dir";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { readdirSync, rmSync, unlinkSync, writeFileSync } from "fs";
|
|
4
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
6
7
|
|
|
7
|
-
export const dynamic =
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
8
9
|
|
|
9
|
-
const DATA_DIR =
|
|
10
|
+
const DATA_DIR = resolveDataDir();
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* POST /api/restore
|
|
@@ -16,66 +17,69 @@ const DATA_DIR = process.env.DATA_DIR || '/data'
|
|
|
16
17
|
*/
|
|
17
18
|
export async function POST(request: NextRequest) {
|
|
18
19
|
try {
|
|
19
|
-
const formData = await request.formData()
|
|
20
|
-
const file = formData.get(
|
|
20
|
+
const formData = await request.formData();
|
|
21
|
+
const file = formData.get("backup") as File | null;
|
|
21
22
|
|
|
22
23
|
if (!file) {
|
|
23
24
|
return NextResponse.json(
|
|
24
25
|
{ error: 'No backup file provided. Upload as "backup" field.' },
|
|
25
|
-
{ status: 400 }
|
|
26
|
-
)
|
|
26
|
+
{ status: 400 },
|
|
27
|
+
);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
// Validate file type
|
|
30
|
-
if (!file.name.endsWith(
|
|
31
|
+
if (!file.name.endsWith(".tar.gz") && !file.name.endsWith(".tgz")) {
|
|
31
32
|
return NextResponse.json(
|
|
32
|
-
{ error:
|
|
33
|
-
{ status: 400 }
|
|
34
|
-
)
|
|
33
|
+
{ error: "Invalid file type. Must be .tar.gz or .tgz" },
|
|
34
|
+
{ status: 400 },
|
|
35
|
+
);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const tmpPath = join(tmpdir(), `restore-${Date.now()}.tar.gz`)
|
|
38
|
+
const tmpPath = join(tmpdir(), `restore-${Date.now()}.tar.gz`);
|
|
38
39
|
|
|
39
40
|
try {
|
|
40
41
|
// Write uploaded file to temp location
|
|
41
|
-
const buffer = Buffer.from(await file.arrayBuffer())
|
|
42
|
-
writeFileSync(tmpPath, buffer)
|
|
42
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
43
|
+
writeFileSync(tmpPath, buffer);
|
|
43
44
|
|
|
44
45
|
// Remove existing database files
|
|
45
|
-
const existingFiles = readdirSync(DATA_DIR)
|
|
46
|
+
const existingFiles = readdirSync(DATA_DIR);
|
|
46
47
|
for (const f of existingFiles) {
|
|
47
|
-
if (f.startsWith(
|
|
48
|
-
try {
|
|
48
|
+
if (f.startsWith("openmemory.sqlite")) {
|
|
49
|
+
try {
|
|
50
|
+
rmSync(join(DATA_DIR, f));
|
|
51
|
+
} catch {}
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
// Extract backup to data directory
|
|
53
|
-
execSync(`tar -xzf "${tmpPath}" -C "${DATA_DIR}"`, { stdio:
|
|
56
|
+
execSync(`tar -xzf "${tmpPath}" -C "${DATA_DIR}"`, { stdio: "pipe" });
|
|
54
57
|
|
|
55
58
|
// Clean up temp file
|
|
56
|
-
unlinkSync(tmpPath)
|
|
59
|
+
unlinkSync(tmpPath);
|
|
57
60
|
|
|
58
61
|
return NextResponse.json({
|
|
59
62
|
success: true,
|
|
60
|
-
message:
|
|
63
|
+
message:
|
|
64
|
+
"Database restored successfully. Restart openmemory container to apply.",
|
|
61
65
|
restartRequired: true,
|
|
62
|
-
restartCommand:
|
|
63
|
-
})
|
|
64
|
-
|
|
66
|
+
restartCommand: "docker restart cybermem-mcp",
|
|
67
|
+
});
|
|
65
68
|
} catch (restoreError: any) {
|
|
66
69
|
// Clean up temp file on error
|
|
67
|
-
try {
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(tmpPath);
|
|
72
|
+
} catch {}
|
|
68
73
|
|
|
69
74
|
return NextResponse.json(
|
|
70
75
|
{ error: `Restore failed: ${restoreError.message}` },
|
|
71
|
-
{ status: 500 }
|
|
72
|
-
)
|
|
76
|
+
{ status: 500 },
|
|
77
|
+
);
|
|
73
78
|
}
|
|
74
|
-
|
|
75
79
|
} catch (error: any) {
|
|
76
80
|
return NextResponse.json(
|
|
77
|
-
{ error: error.message ||
|
|
78
|
-
{ status: 500 }
|
|
79
|
-
)
|
|
81
|
+
{ error: error.message || "Unknown error" },
|
|
82
|
+
{ status: 500 },
|
|
83
|
+
);
|
|
80
84
|
}
|
|
81
85
|
}
|
|
@@ -120,11 +120,13 @@ export async function GET(request: NextRequest) {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
let apiKey = process.env.OM_API_KEY || "not-set";
|
|
123
|
+
let tokenSource = "env";
|
|
123
124
|
|
|
124
125
|
// Try to read from HttpOnly cookie first
|
|
125
126
|
const cookieKey = request.cookies.get("cybermem_api_key")?.value;
|
|
126
127
|
if (cookieKey) {
|
|
127
128
|
apiKey = cookieKey;
|
|
129
|
+
tokenSource = "cookie";
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
// Fallback to config file
|
|
@@ -132,7 +134,10 @@ export async function GET(request: NextRequest) {
|
|
|
132
134
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
133
135
|
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
134
136
|
const conf = JSON.parse(raw);
|
|
135
|
-
if (conf.api_key && apiKey === "not-set")
|
|
137
|
+
if (conf.api_key && apiKey === "not-set") {
|
|
138
|
+
apiKey = conf.api_key;
|
|
139
|
+
tokenSource = "config";
|
|
140
|
+
}
|
|
136
141
|
}
|
|
137
142
|
} catch (e) {
|
|
138
143
|
// ignore
|
|
@@ -143,7 +148,45 @@ export async function GET(request: NextRequest) {
|
|
|
143
148
|
const secretPath = "/run/secrets/om_api_key";
|
|
144
149
|
if (fs.existsSync(secretPath)) {
|
|
145
150
|
const secret = fs.readFileSync(secretPath, "utf-8").trim();
|
|
146
|
-
if (secret)
|
|
151
|
+
if (secret) {
|
|
152
|
+
if (secret.startsWith("sk-")) {
|
|
153
|
+
apiKey = secret;
|
|
154
|
+
tokenSource = "docker-secret";
|
|
155
|
+
} else {
|
|
156
|
+
// Fallback: support env-file format like "OM_API_KEY=sk-..."
|
|
157
|
+
const envMatch = secret.match(
|
|
158
|
+
/OM_API_KEY=["']?(sk-[a-zA-Z0-9]+)["']?/,
|
|
159
|
+
);
|
|
160
|
+
if (envMatch) {
|
|
161
|
+
apiKey = envMatch[1];
|
|
162
|
+
tokenSource = "docker-secret-env";
|
|
163
|
+
} else {
|
|
164
|
+
console.warn(
|
|
165
|
+
`[Settings API] Token at ${secretPath} doesn't match expected format (sk-* or OM_API_KEY=sk-*)`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
// ignore
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Fallback: Auto-generated token location
|
|
176
|
+
try {
|
|
177
|
+
const fallbackPath = "/data/.cybermem_token";
|
|
178
|
+
if (apiKey === "not-set" && fs.existsSync(fallbackPath)) {
|
|
179
|
+
const token = fs.readFileSync(fallbackPath, "utf-8").trim();
|
|
180
|
+
if (token) {
|
|
181
|
+
if (token.startsWith("sk-")) {
|
|
182
|
+
apiKey = token;
|
|
183
|
+
tokenSource = "fallback";
|
|
184
|
+
} else {
|
|
185
|
+
console.warn(
|
|
186
|
+
`[Settings API] Token at ${fallbackPath} doesn't match expected format (sk-*)`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
147
190
|
}
|
|
148
191
|
} catch (e) {
|
|
149
192
|
// ignore
|
|
@@ -158,6 +201,16 @@ export async function GET(request: NextRequest) {
|
|
|
158
201
|
// isManaged = Local Mode (localhost auto-login)
|
|
159
202
|
const isManaged = isLocal;
|
|
160
203
|
|
|
204
|
+
// Mask the token for public display
|
|
205
|
+
const maskToken = (token: string) => {
|
|
206
|
+
if (!token || token === "not-set") return token;
|
|
207
|
+
if (token.length <= 10) return "****";
|
|
208
|
+
// sk-abcd...efgh
|
|
209
|
+
return `${token.slice(0, 7)}...${token.slice(-4)}`;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const maskedApiKey = maskToken(apiKey);
|
|
213
|
+
|
|
161
214
|
// Read version from package.json
|
|
162
215
|
let version = "v0.11.4"; // Default fallback
|
|
163
216
|
try {
|
|
@@ -176,8 +229,9 @@ export async function GET(request: NextRequest) {
|
|
|
176
229
|
|
|
177
230
|
return NextResponse.json(
|
|
178
231
|
{
|
|
179
|
-
token: apiKey,
|
|
180
232
|
apiKey: apiKey,
|
|
233
|
+
apiKeyMasked: maskedApiKey,
|
|
234
|
+
tokenSource: apiKey === "not-set" ? "not-set" : tokenSource,
|
|
181
235
|
endpoint,
|
|
182
236
|
isManaged,
|
|
183
237
|
isLocal,
|
|
@@ -7,6 +7,7 @@ import { Check, Copy, Eye, EyeOff, RotateCcw, Shield } from "lucide-react";
|
|
|
7
7
|
|
|
8
8
|
interface AccessTokenSectionProps {
|
|
9
9
|
apiKey: string;
|
|
10
|
+
apiKeyMasked: string;
|
|
10
11
|
showApiKey: boolean;
|
|
11
12
|
setShowApiKey: (show: boolean) => void;
|
|
12
13
|
copiedId: string | null;
|
|
@@ -18,6 +19,7 @@ interface AccessTokenSectionProps {
|
|
|
18
19
|
|
|
19
20
|
export default function AccessTokenSection({
|
|
20
21
|
apiKey,
|
|
22
|
+
apiKeyMasked,
|
|
21
23
|
showApiKey,
|
|
22
24
|
setShowApiKey,
|
|
23
25
|
copiedId,
|
|
@@ -40,7 +42,10 @@ export default function AccessTokenSection({
|
|
|
40
42
|
<div className="relative flex-1">
|
|
41
43
|
<Input
|
|
42
44
|
id="access-token"
|
|
43
|
-
value={
|
|
45
|
+
value={
|
|
46
|
+
(showApiKey ? apiKey : apiKeyMasked) ||
|
|
47
|
+
"Token not generated yet"
|
|
48
|
+
}
|
|
44
49
|
readOnly
|
|
45
50
|
className="bg-black/40 border-[0.5px] border-white/10 text-white font-mono text-sm pr-10"
|
|
46
51
|
type={showApiKey ? "text" : "password"}
|
|
@@ -12,6 +12,7 @@ import SystemInfoSection from "./settings/system-info-section";
|
|
|
12
12
|
|
|
13
13
|
export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
14
14
|
const [apiKey, setApiKey] = useState("");
|
|
15
|
+
const [apiKeyMasked, setApiKeyMasked] = useState("");
|
|
15
16
|
const [endpoint, setEndpoint] = useState("");
|
|
16
17
|
const [isManaged, setIsManaged] = useState(false);
|
|
17
18
|
const [instanceType, setInstanceType] = useState<"local" | "rpi" | "vps">(
|
|
@@ -58,8 +59,16 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
58
59
|
|
|
59
60
|
if (localKey && !data.isManaged) {
|
|
60
61
|
setApiKey(localKey);
|
|
62
|
+
// Mask the local key for display
|
|
63
|
+
const maskedLocal = localKey.length > 10
|
|
64
|
+
? `${localKey.slice(0, 7)}...${localKey.slice(-4)}`
|
|
65
|
+
: "****";
|
|
66
|
+
setApiKeyMasked(maskedLocal);
|
|
61
67
|
} else {
|
|
62
68
|
setApiKey(data.apiKey !== "not-set" ? data.apiKey : "");
|
|
69
|
+
setApiKeyMasked(
|
|
70
|
+
data.apiKeyMasked !== "not-set" ? data.apiKeyMasked : "",
|
|
71
|
+
);
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
let srvEndpoint = data.endpoint;
|
|
@@ -191,7 +200,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
191
200
|
description: data.message || "Database wiped successfully.",
|
|
192
201
|
});
|
|
193
202
|
} catch (err: any) {
|
|
203
|
+
setShowResetConfirm(false);
|
|
194
204
|
setOperationStatus({ type: "error", message: err.message });
|
|
205
|
+
toast.error("Reset Failed", {
|
|
206
|
+
description: err.message,
|
|
207
|
+
});
|
|
195
208
|
} finally {
|
|
196
209
|
setIsResetting(false);
|
|
197
210
|
}
|
|
@@ -223,6 +236,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
223
236
|
<div className="flex-1 overflow-y-auto p-8 space-y-8 bg-[#05100F]">
|
|
224
237
|
<AccessTokenSection
|
|
225
238
|
apiKey={apiKey}
|
|
239
|
+
apiKeyMasked={apiKeyMasked}
|
|
226
240
|
showApiKey={showApiKey}
|
|
227
241
|
setShowApiKey={setShowApiKey}
|
|
228
242
|
copiedId={copiedId}
|
package/e2e/api.spec.ts
CHANGED
|
@@ -215,5 +215,28 @@ test.describe("Dashboard:E2E:API (Deep Verification)", () => {
|
|
|
215
215
|
contentType: "text/plain",
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
await test.step("⚙️ Settings Fallback — Verify tokenSource and Masking", async () => {
|
|
220
|
+
console.log("⚙️ GET /api/settings (Fallback/Auto-gen Verification)");
|
|
221
|
+
|
|
222
|
+
const settingsResp = await request.get(`${DASHBOARD_URL}/api/settings`, {
|
|
223
|
+
headers: getHeaders("antigravity-client"),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const settings = await settingsResp.json();
|
|
227
|
+
console.log(` Token Source: ${settings.tokenSource}`);
|
|
228
|
+
|
|
229
|
+
expect(settingsResp.status()).toBe(200);
|
|
230
|
+
expect(settings).toHaveProperty("tokenSource");
|
|
231
|
+
// Verify apiKeyMasked is masked (not the raw apiKey field)
|
|
232
|
+
if (settings.apiKeyMasked && settings.apiKeyMasked !== "not-set") {
|
|
233
|
+
expect(settings.apiKeyMasked.length).toBeLessThanOrEqual(14); // 7 + 3 + 4 = 14
|
|
234
|
+
expect(settings.apiKeyMasked).toMatch(/^sk-.*\.\.\..*$/);
|
|
235
|
+
}
|
|
236
|
+
// Verify apiKey contains the raw token (for UI copy functionality)
|
|
237
|
+
if (settings.apiKey && settings.apiKey !== "not-set") {
|
|
238
|
+
expect(settings.apiKey).toMatch(/^sk-[a-f0-9]{32}$/);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
218
241
|
});
|
|
219
242
|
});
|
package/e2e/reset-db.spec.ts
CHANGED
|
@@ -276,4 +276,73 @@ test.describe("Reset DB - Settings Modal", () => {
|
|
|
276
276
|
|
|
277
277
|
console.log("✅ Cancel closes confirmation, Settings modal persists");
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
test("6. Reset API never returns ENOENT (DATA_DIR resolved)", async ({
|
|
281
|
+
request,
|
|
282
|
+
}) => {
|
|
283
|
+
// Call the reset API directly and verify we never get ENOENT
|
|
284
|
+
const res = await request.post("/api/reset", {
|
|
285
|
+
data: { confirm: "RESET" },
|
|
286
|
+
headers: { "Content-Type": "application/json" },
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const body = await res.json();
|
|
290
|
+
|
|
291
|
+
// Must NOT contain ENOENT — this was the RPi bug
|
|
292
|
+
if (body.error) {
|
|
293
|
+
expect(body.error).not.toContain("ENOENT");
|
|
294
|
+
expect(body.error).not.toContain("no such file or directory");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Should succeed (200) — file deletion works
|
|
298
|
+
expect(res.status()).toBe(200);
|
|
299
|
+
expect(body.success).toBe(true);
|
|
300
|
+
console.log(
|
|
301
|
+
`✅ Reset API succeeded without ENOENT (deleted ${body.deletedCount} files)`,
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("7. On API error, confirmation modal closes and error toast shown", async ({
|
|
306
|
+
page,
|
|
307
|
+
}) => {
|
|
308
|
+
await openSettings(page);
|
|
309
|
+
|
|
310
|
+
const resetBtn = page.getByRole("button", { name: /Reset DB/i });
|
|
311
|
+
await resetBtn.click();
|
|
312
|
+
|
|
313
|
+
await expect(
|
|
314
|
+
page.getByRole("heading", { name: "Reset Database" }),
|
|
315
|
+
).toBeVisible();
|
|
316
|
+
|
|
317
|
+
// Intercept the reset API to simulate a server error
|
|
318
|
+
await page.route("**/api/reset", async (route) => {
|
|
319
|
+
await route.fulfill({
|
|
320
|
+
status: 500,
|
|
321
|
+
contentType: "application/json",
|
|
322
|
+
body: JSON.stringify({ error: "Simulated server error for E2E test" }),
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const confirmBtn = page.getByRole("button", { name: /Reset Database/i });
|
|
327
|
+
const input = page.getByPlaceholder(/Type "RESET" to confirm/i);
|
|
328
|
+
|
|
329
|
+
await input.click();
|
|
330
|
+
await input.fill("RESET");
|
|
331
|
+
await expect(confirmBtn).toBeEnabled();
|
|
332
|
+
await confirmBtn.click();
|
|
333
|
+
|
|
334
|
+
// Confirmation modal MUST close even on error
|
|
335
|
+
await expect(page.getByPlaceholder(/Type "RESET" to confirm/i)).toBeHidden({
|
|
336
|
+
timeout: 10000,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Error toast must appear
|
|
340
|
+
await expect(
|
|
341
|
+
page.locator('[data-sonner-toaster] [data-type="error"]'),
|
|
342
|
+
).toBeVisible({ timeout: 5000 });
|
|
343
|
+
|
|
344
|
+
console.log(
|
|
345
|
+
"✅ On API error: modal closed, error toast shown (not hidden behind modal)",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
279
348
|
});
|
package/e2e/ui.spec.ts
CHANGED
|
@@ -199,8 +199,10 @@ test.describe("Dashboard:E2E:UI (High-Fidelity Mocks)", () => {
|
|
|
199
199
|
contentType: "application/json",
|
|
200
200
|
body: JSON.stringify({
|
|
201
201
|
apiKey: MOCK_API_KEY,
|
|
202
|
+
apiKeyMasked: "sk-e2e-...2345",
|
|
202
203
|
instanceId: "local-dev-mock",
|
|
203
204
|
instanceType: "local",
|
|
205
|
+
isManaged: true,
|
|
204
206
|
endpoint: "http://localhost:8626/mcp",
|
|
205
207
|
}),
|
|
206
208
|
});
|
|
@@ -210,7 +212,7 @@ test.describe("Dashboard:E2E:UI (High-Fidelity Mocks)", () => {
|
|
|
210
212
|
description: `API Key: ${MOCK_API_KEY.substring(0, 10)}...`,
|
|
211
213
|
});
|
|
212
214
|
|
|
213
|
-
// 5. Mock MCP Config
|
|
215
|
+
// 5. Mock MCP Config (local env = @cybermem/mcp direct, no --url)
|
|
214
216
|
await page.route("**/api/mcp-config*", async (route) => {
|
|
215
217
|
await route.fulfill({
|
|
216
218
|
status: 200,
|
|
@@ -219,19 +221,19 @@ test.describe("Dashboard:E2E:UI (High-Fidelity Mocks)", () => {
|
|
|
219
221
|
configType: "json",
|
|
220
222
|
config: {
|
|
221
223
|
mcpServers: {
|
|
222
|
-
|
|
224
|
+
cybermem: {
|
|
223
225
|
command: "npx",
|
|
224
|
-
args: ["@cybermem/mcp"
|
|
225
|
-
env: { X_CLIENT_NAME: MOCK_IDENTITY_WRITER },
|
|
226
|
+
args: ["-y", "@cybermem/mcp"],
|
|
226
227
|
},
|
|
227
228
|
},
|
|
228
229
|
},
|
|
230
|
+
isManaged: true,
|
|
229
231
|
}),
|
|
230
232
|
});
|
|
231
233
|
});
|
|
232
234
|
appliedMocks.push({
|
|
233
235
|
endpoint: "GET /api/mcp-config",
|
|
234
|
-
description: "MCP config with npx @cybermem/mcp",
|
|
236
|
+
description: "MCP config with npx @cybermem/mcp (local)",
|
|
235
237
|
});
|
|
236
238
|
});
|
|
237
239
|
|
|
@@ -326,9 +328,9 @@ test.describe("Dashboard:E2E:UI (High-Fidelity Mocks)", () => {
|
|
|
326
328
|
await expect(page.getByText(/ACCESS TOKEN/i).first()).toBeVisible();
|
|
327
329
|
});
|
|
328
330
|
|
|
329
|
-
await test.step(`Verify Token Matches Mock
|
|
331
|
+
await test.step(`Verify Token Matches Masked Mock by Default`, async () => {
|
|
330
332
|
const input = page.locator("input#access-token");
|
|
331
|
-
await expect(input).toHaveValue(
|
|
333
|
+
await expect(input).toHaveValue("sk-e2e-...2345");
|
|
332
334
|
});
|
|
333
335
|
|
|
334
336
|
await test.step("Toggle Token Visibility — password → text", async () => {
|
|
@@ -336,6 +338,7 @@ test.describe("Dashboard:E2E:UI (High-Fidelity Mocks)", () => {
|
|
|
336
338
|
await expect(input).toHaveAttribute("type", "password");
|
|
337
339
|
await page.getByTestId("toggle-visibility").click();
|
|
338
340
|
await expect(input).toHaveAttribute("type", "text");
|
|
341
|
+
await expect(input).toHaveValue(MOCK_API_KEY);
|
|
339
342
|
});
|
|
340
343
|
|
|
341
344
|
await flushNetwork();
|
|
@@ -357,14 +360,14 @@ test.describe("Dashboard:E2E:UI (High-Fidelity Mocks)", () => {
|
|
|
357
360
|
await page.getByRole("button", { name: MOCK_IDENTITY_WRITER }).click();
|
|
358
361
|
});
|
|
359
362
|
|
|
360
|
-
await test.step("Verify CLI Install Command — npx @cybermem/mcp", async () => {
|
|
363
|
+
await test.step("Verify CLI Install Command — npx @cybermem/mcp (local, no --url)", async () => {
|
|
361
364
|
const codeBlock = page.locator("pre code");
|
|
362
365
|
await expect(codeBlock).toContainText("npx");
|
|
363
366
|
await expect(codeBlock).toContainText("@cybermem/mcp");
|
|
364
367
|
});
|
|
365
368
|
|
|
366
369
|
await testInfo.attach("🚀 CLI Installation Command", {
|
|
367
|
-
body: `npx @cybermem/cli init\nnpx @cybermem/cli up\n\nMCP Server Config:\n"cybermem
|
|
370
|
+
body: `npx @cybermem/cli init\nnpx @cybermem/cli up\n\nMCP Server Config (local):\n"cybermem": {\n "command": "npx",\n "args": ["-y", "@cybermem/mcp"]\n}`,
|
|
368
371
|
contentType: "text/plain",
|
|
369
372
|
});
|
|
370
373
|
|
package/eslint.config.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import nextConfig from
|
|
1
|
+
import nextConfig from "eslint-config-next";
|
|
2
2
|
|
|
3
3
|
const eslintConfig = [
|
|
4
|
+
{
|
|
5
|
+
ignores: ["playwright-report/**", ".next/**", "test-results/**"],
|
|
6
|
+
},
|
|
4
7
|
...nextConfig,
|
|
5
8
|
{
|
|
6
9
|
rules: {
|
|
7
10
|
// Disable false-positive for sessionStorage reads in useEffect
|
|
8
|
-
|
|
11
|
+
"react-hooks/set-state-in-effect": "off",
|
|
9
12
|
// Disable for shadcn/ui components that use Math.random() in useMemo
|
|
10
|
-
|
|
13
|
+
"react-hooks/purity": "off",
|
|
11
14
|
},
|
|
12
15
|
},
|
|
13
16
|
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the CyberMem data directory depending on the runtime environment:
|
|
7
|
+
*
|
|
8
|
+
* 1. Explicit `DATA_DIR` env var (highest priority)
|
|
9
|
+
* 2. Docker volume mount at `/data` (container runtime)
|
|
10
|
+
* 3. `~/.cybermem/data` (local dev / Next.js standalone)
|
|
11
|
+
*/
|
|
12
|
+
export function resolveDataDir(): string {
|
|
13
|
+
const envDataDir = process.env.DATA_DIR?.trim();
|
|
14
|
+
if (envDataDir) return envDataDir;
|
|
15
|
+
if (existsSync("/data")) return "/data";
|
|
16
|
+
return resolve(homedir(), ".cybermem/data");
|
|
17
|
+
}
|
package/package.json
CHANGED
package/public/clients.json
CHANGED
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
],
|
|
95
95
|
"configType": "command",
|
|
96
96
|
"localCommand": "claude mcp add cybermem npx @cybermem/mcp",
|
|
97
|
-
"remoteCommand": "claude mcp add cybermem -- npx
|
|
97
|
+
"remoteCommand": "claude mcp add cybermem -- npx -y mcp-remote {{ENDPOINT}} --header X-API-Key:{{TOKEN}}"
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
"id": "chatgpt",
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
],
|
|
159
159
|
"configType": "cmd",
|
|
160
160
|
"localCommand": "gemini mcp add cybermem npx @cybermem/mcp",
|
|
161
|
-
"remoteCommand": "gemini mcp add cybermem -- npx
|
|
161
|
+
"remoteCommand": "gemini mcp add cybermem -- npx -y mcp-remote {{ENDPOINT}} --header X-API-Key:{{TOKEN}}"
|
|
162
162
|
},
|
|
163
163
|
{
|
|
164
164
|
"id": "other",
|