@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 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
@@ -1,12 +1,13 @@
1
- import { execSync } from 'child_process'
2
- import { createReadStream, rmSync, statSync } from 'fs'
3
- import { NextResponse } from 'next/server'
4
- import { tmpdir } from 'os'
5
- import { join } from 'path'
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 = 'force-dynamic'
8
+ export const dynamic = "force-dynamic";
8
9
 
9
- const DATA_DIR = process.env.DATA_DIR || '/data'
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().toISOString().replace(/[:.]/g, '-').slice(0, 19)
18
- const backupName = `cybermem-backup-${timestamp}.tar.gz`
19
- const tmpPath = join(tmpdir(), backupName)
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: 'pipe' })
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('data', (chunk) => controller.enqueue(chunk))
35
- stream.on('end', () => {
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 { rmSync(tmpPath) } catch {}
39
- })
40
- stream.on('error', (err) => controller.error(err))
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
- 'Content-Type': 'application/gzip',
47
- 'Content-Disposition': `attachment; filename="${backupName}"`,
48
- 'Content-Length': String(stats.size)
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 || 'Unknown error' },
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
- config = `[mcpServers.cybermem]\ncommand = "npx"\nargs = ["@cybermem/mcp"]`;
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 = ["@cybermem/mcp", "--url", "${baseUrl}", "--token", "${keyVal}"]`;
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
- let cmd = isManaged ? client?.localCommand : client?.remoteCommand;
79
- if (!cmd) {
80
- cmd = client?.command?.replace("http://localhost:8080", baseUrl) || "";
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
- const args = isManaged
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 (isStaging && !isManaged) {
100
- args.push("--staging");
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 = {
@@ -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 { homedir } from "os";
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 = process.env.DATA_DIR || resolve(homedir(), ".cybermem/data");
8
+ const DATA_DIR = resolveDataDir();
9
9
 
10
10
  /**
11
11
  * POST /api/reset
@@ -1,12 +1,13 @@
1
- import { execSync } from 'child_process'
2
- import { readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs'
3
- import { NextRequest, NextResponse } from 'next/server'
4
- import { tmpdir } from 'os'
5
- import { join } from 'path'
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 = 'force-dynamic'
8
+ export const dynamic = "force-dynamic";
8
9
 
9
- const DATA_DIR = process.env.DATA_DIR || '/data'
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('backup') as File | null
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('.tar.gz') && !file.name.endsWith('.tgz')) {
31
+ if (!file.name.endsWith(".tar.gz") && !file.name.endsWith(".tgz")) {
31
32
  return NextResponse.json(
32
- { error: 'Invalid file type. Must be .tar.gz or .tgz' },
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('openmemory.sqlite')) {
48
- try { rmSync(join(DATA_DIR, f)) } catch {}
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: 'pipe' })
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: 'Database restored successfully. Restart openmemory container to apply.',
63
+ message:
64
+ "Database restored successfully. Restart openmemory container to apply.",
61
65
  restartRequired: true,
62
- restartCommand: 'docker restart cybermem-mcp'
63
- })
64
-
66
+ restartCommand: "docker restart cybermem-mcp",
67
+ });
65
68
  } catch (restoreError: any) {
66
69
  // Clean up temp file on error
67
- try { unlinkSync(tmpPath) } catch {}
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 || 'Unknown error' },
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") apiKey = conf.api_key;
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) apiKey = 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={apiKey || "Token not generated yet"}
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
  });
@@ -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
- "cybermem-mcp": {
224
+ cybermem: {
223
225
  command: "npx",
224
- args: ["@cybermem/mcp", "--url", "http://localhost:8626"],
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 ${MOCK_API_KEY}`, async () => {
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(MOCK_API_KEY);
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-mcp": {\n "command": "npx",\n "args": ["@cybermem/mcp", "--url", "http://localhost:8626"]\n}`,
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 'eslint-config-next';
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
- 'react-hooks/set-state-in-effect': 'off',
11
+ "react-hooks/set-state-in-effect": "off",
9
12
  // Disable for shadcn/ui components that use Math.random() in useMemo
10
- 'react-hooks/purity': 'off',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cybermem/dashboard",
3
- "version": "0.13.16",
3
+ "version": "0.14.4",
4
4
  "description": "CyberMem Monitoring Dashboard",
5
5
  "homepage": "https://cybermem.dev",
6
6
  "repository": {
@@ -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 @cybermem/mcp --url {{ENDPOINT}} --token {{TOKEN}}"
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 @cybermem/mcp --url {{ENDPOINT}} --token {{TOKEN}}"
161
+ "remoteCommand": "gemini mcp add cybermem -- npx -y mcp-remote {{ENDPOINT}} --header X-API-Key:{{TOKEN}}"
162
162
  },
163
163
  {
164
164
  "id": "other",