refactor: finish the AgentHub rename and drop the MCP server
Two leftovers from the rebrand, done together because they touch the same docs and manifests. **Data directory.** `agent-dashboard` could not simply be renamed in the source: it names a real directory holding the live SQLite database. Startup now moves it once, via `migrateLegacyDataDir()` in `server/lib/claude-home.js` — a same-filesystem rename, so it is instant even at multiple gigabytes and cannot leave two divergent copies. `getDataDir()` stays PURE and resolves to the legacy path until the move happens; the first version of this put the rename inside the getter, which meant `npm run test:server` relocated the developer's real database as a side effect. The call sits behind `require.main === module` and ahead of the router requires, because those pull in db.js, which opens SQLite at module load. A failed rename is silent and the old directory keeps being used: an unfashionable directory name beats a blank database beside an unreachable one. Same treatment for the port-discovery file (`.agent-dashboard.json` → `.agenthub.json`, old one dropped on write). Not renamed on purpose: the Sagi localStorage keys (renaming silently resets everyone's prefs) and the PWA manifest `id` (changing it orphans an installed app). **MCP server.** 4 tool calls in 46 days across 577,132 events, against 4,556 lines of TypeScript, a 2 MB committed build artifact, and a pre-commit guard that had already blocked commits twice. Deleted: `mcp/`, the maintainer skills and `mcp-reviewer` agent, the build check, `plugins/agenthub/.mcp.json`, `docs/MCP.md`, 12 npm scripts, 5 Makefile targets, and every doc section describing it. Kept, because they are unrelated things sharing the name: the Config Explorer's MCP tab and its API, per-lane Playwright MCP, the agenthub-config plugin's MCP audit, and the historical `mcp__plugin_agenthub_*` events that analytics must keep attributing. Verified: server 1432/1432, client 459/459, tsc clean, prettier clean. Both directions of the migration proven — a sandbox-HOME test run leaves the legacy directory untouched, and a real boot moves it with its contents intact.
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
* spawnSync): the dashboard server lives in THIS process, so a synchronous
|
||||
* spawn would block the event loop and deadlock every request the child
|
||||
* makes. Port targeting uses the DASHBOARD_PORT env override, which takes
|
||||
* precedence over ~/.claude/.agent-dashboard.json discovery.
|
||||
* precedence over ~/.claude/.agenthub.json discovery.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* @file Tests scripts/check-mcp-build.js — the freshness gate for the committed
|
||||
* mcp/build artifact. Content hashing is the whole point: mtimes are meaningless
|
||||
* after a clone, where every file is stamped at checkout time in arbitrary order.
|
||||
* Runs against synthetic trees, never the real mcp/.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const { sourceHash, mcpBuildStatus, writeHash } = require("../../scripts/check-mcp-build");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-mcpbuild-"));
|
||||
const SRC = path.join(ROOT, "mcp", "src");
|
||||
const BUILD = path.join(ROOT, "mcp", "build");
|
||||
|
||||
function makeTree() {
|
||||
fs.rmSync(path.join(ROOT, "mcp"), { recursive: true, force: true });
|
||||
fs.mkdirSync(SRC, { recursive: true });
|
||||
fs.mkdirSync(BUILD, { recursive: true });
|
||||
fs.writeFileSync(path.join(SRC, "index.ts"), "export const a = 1;\n");
|
||||
fs.writeFileSync(path.join(ROOT, "mcp", "package.json"), '{"name":"x"}\n');
|
||||
fs.writeFileSync(path.join(BUILD, "index.js"), "exports.a = 1;\n");
|
||||
}
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
describe("mcp build freshness", () => {
|
||||
beforeEach(makeTree);
|
||||
|
||||
it("fails when the build has no recorded hash", () => {
|
||||
const status = mcpBuildStatus(ROOT);
|
||||
assert.equal(status.ok, false);
|
||||
assert.match(status.reason, /no recorded source hash/);
|
||||
});
|
||||
|
||||
it("passes right after the hash is stamped", () => {
|
||||
writeHash(ROOT);
|
||||
assert.equal(mcpBuildStatus(ROOT).ok, true);
|
||||
});
|
||||
|
||||
it("fails when a source file changes after the build", () => {
|
||||
writeHash(ROOT);
|
||||
fs.writeFileSync(path.join(SRC, "index.ts"), "export const a = 2;\n");
|
||||
const status = mcpBuildStatus(ROOT);
|
||||
assert.equal(status.ok, false);
|
||||
assert.match(status.reason, /stale/);
|
||||
});
|
||||
|
||||
it("fails when a source file is added after the build", () => {
|
||||
writeHash(ROOT);
|
||||
fs.writeFileSync(path.join(SRC, "extra.ts"), "export const b = 1;\n");
|
||||
assert.equal(mcpBuildStatus(ROOT).ok, false);
|
||||
});
|
||||
|
||||
it("fails when the build output is missing entirely", () => {
|
||||
writeHash(ROOT);
|
||||
fs.rmSync(path.join(BUILD, "index.js"));
|
||||
assert.match(mcpBuildStatus(ROOT).reason, /missing/);
|
||||
});
|
||||
|
||||
it("ignores modification times — only content counts", () => {
|
||||
const before = sourceHash(ROOT);
|
||||
const future = Date.now() / 1000 + 10_000;
|
||||
fs.utimesSync(path.join(SRC, "index.ts"), future, future);
|
||||
assert.equal(sourceHash(ROOT), before);
|
||||
});
|
||||
|
||||
it("tracks the manifest, so a dependency bump invalidates the build", () => {
|
||||
writeHash(ROOT);
|
||||
fs.writeFileSync(path.join(ROOT, "mcp", "package.json"), '{"name":"x","version":"2"}\n');
|
||||
assert.equal(mcpBuildStatus(ROOT).ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the committed mcp/build in this repo", () => {
|
||||
it("matches mcp/src", () => {
|
||||
const status = mcpBuildStatus(path.resolve(__dirname, "..", ".."));
|
||||
assert.equal(status.ok, true, status.reason);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file Tests the data-directory resolution in claude-home.js, and the one-time
|
||||
* migration that carries an existing `~/.claude/agent-dashboard` (the name the
|
||||
* product used before the AgentHub rebrand) over to `~/.claude/agenthub`.
|
||||
*
|
||||
* The stakes are why this is tested at all: that directory holds the live
|
||||
* SQLite database. Resolving to the new path when the data is still under the
|
||||
* old one would not lose the data, but it WOULD silently start an empty
|
||||
* dashboard, which looks exactly like data loss to whoever opens it.
|
||||
*
|
||||
* The split matters as much as the migration. `getDataDir()` is a getter that
|
||||
* gets asked for the path by tests, doctors and `--help`; if it also moved
|
||||
* directories, every one of those would relocate a multi-gigabyte database as a
|
||||
* side effect. So it is asserted pure here, and the move lives in its own
|
||||
* function that only server startup calls.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-datadir-"));
|
||||
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
||||
|
||||
// claude-home reads CLAUDE_HOME on every call, so each test can point it
|
||||
// somewhere fresh without reloading the module.
|
||||
const {
|
||||
getDataDir,
|
||||
migrateLegacyDataDir,
|
||||
getTranscriptSnapshotDir,
|
||||
} = require("../lib/claude-home");
|
||||
|
||||
let home;
|
||||
let seq = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
seq += 1;
|
||||
home = path.join(SUITE_ROOT, `home${seq}`, ".claude");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
process.env.CLAUDE_HOME = home;
|
||||
delete process.env.DASHBOARD_DATA_DIR;
|
||||
});
|
||||
|
||||
const legacy = () => path.join(home, "agent-dashboard");
|
||||
const current = () => path.join(home, "agenthub");
|
||||
|
||||
/** Put a recognisable database in `dir` so a migration can be proven. */
|
||||
function seedDataDir(dir, marker) {
|
||||
fs.mkdirSync(path.join(dir, "transcripts"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "dashboard.db"), marker);
|
||||
}
|
||||
|
||||
describe("getDataDir", () => {
|
||||
it("uses the new name on a fresh install", () => {
|
||||
assert.equal(getDataDir(), current());
|
||||
});
|
||||
|
||||
it("resolves to the legacy directory until it is migrated, and moves nothing", () => {
|
||||
seedDataDir(legacy(), "the-real-database");
|
||||
|
||||
assert.equal(getDataDir(), legacy(), "the data is still there, so that is the answer");
|
||||
assert.deepEqual(fs.readdirSync(home), ["agent-dashboard"], "resolving must not move anything");
|
||||
});
|
||||
|
||||
it("prefers the new directory once both exist", () => {
|
||||
seedDataDir(legacy(), "old");
|
||||
seedDataDir(current(), "new");
|
||||
|
||||
assert.equal(getDataDir(), current());
|
||||
});
|
||||
|
||||
it("lets DASHBOARD_DATA_DIR win", () => {
|
||||
seedDataDir(legacy(), "db");
|
||||
const pinned = path.join(SUITE_ROOT, `pinned${seq}`);
|
||||
process.env.DASHBOARD_DATA_DIR = pinned;
|
||||
|
||||
assert.equal(getDataDir(), pinned);
|
||||
});
|
||||
|
||||
it("derives the transcript snapshot dir from the migrated location", () => {
|
||||
seedDataDir(legacy(), "db");
|
||||
migrateLegacyDataDir();
|
||||
assert.equal(getTranscriptSnapshotDir(), path.join(current(), "transcripts"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateLegacyDataDir", () => {
|
||||
it("moves an existing legacy directory to the new name, keeping its contents", () => {
|
||||
seedDataDir(legacy(), "the-real-database");
|
||||
|
||||
assert.equal(migrateLegacyDataDir(), current());
|
||||
assert.equal(fs.existsSync(legacy()), false, "the legacy directory should be gone");
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"),
|
||||
"the-real-database"
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(current(), "transcripts")), "nested content must survive");
|
||||
assert.equal(getDataDir(), current());
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", () => {
|
||||
assert.equal(migrateLegacyDataDir(), null);
|
||||
assert.deepEqual(fs.readdirSync(home), [], "no directory should be created just by migrating");
|
||||
});
|
||||
|
||||
it("migrates once and stays put on later calls", () => {
|
||||
seedDataDir(legacy(), "db");
|
||||
migrateLegacyDataDir();
|
||||
fs.writeFileSync(path.join(current(), "dashboard.db"), "written-after-migration");
|
||||
|
||||
assert.equal(migrateLegacyDataDir(), null);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"),
|
||||
"written-after-migration",
|
||||
"a second call must not re-migrate over newer data"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves both alone when the new directory already exists", () => {
|
||||
// Nothing sensible to merge here, and picking wrong would hide a database:
|
||||
// the new one wins and the legacy one is left untouched for the user.
|
||||
seedDataDir(legacy(), "old");
|
||||
seedDataDir(current(), "new");
|
||||
|
||||
assert.equal(migrateLegacyDataDir(), null);
|
||||
assert.equal(fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"), "new");
|
||||
assert.equal(fs.readFileSync(path.join(legacy(), "dashboard.db"), "utf8"), "old");
|
||||
});
|
||||
|
||||
it("keeps using the legacy directory when it cannot be moved", () => {
|
||||
// A read-only Claude home makes the rename fail. Starting a blank database
|
||||
// beside 2 GB of unreachable history is the one outcome worth avoiding.
|
||||
seedDataDir(legacy(), "db");
|
||||
fs.chmodSync(home, 0o500);
|
||||
try {
|
||||
assert.equal(migrateLegacyDataDir(), null, "a failed move must not be reported as done");
|
||||
assert.equal(getDataDir(), legacy());
|
||||
} finally {
|
||||
fs.chmodSync(home, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
it("never migrates behind an explicit DASHBOARD_DATA_DIR", () => {
|
||||
seedDataDir(legacy(), "db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, `pinned${seq}`);
|
||||
|
||||
assert.equal(migrateLegacyDataDir(), null);
|
||||
assert.ok(fs.existsSync(legacy()), "an explicit data dir must not trigger a migration");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ delete process.env.DASHBOARD_DATA_DIR;
|
||||
|
||||
const boot = require("../../scripts/plugin-bootstrap");
|
||||
|
||||
const RT = path.join(TMP_HOME, "agent-dashboard", "runtime");
|
||||
const RT = path.join(TMP_HOME, "agenthub", "runtime");
|
||||
|
||||
function resetRuntime() {
|
||||
fs.rmSync(RT, { recursive: true, force: true });
|
||||
@@ -32,7 +32,7 @@ after(() => {
|
||||
});
|
||||
|
||||
describe("serverIsLive", () => {
|
||||
const infoPath = path.join(TMP_HOME, ".agent-dashboard.json");
|
||||
const infoPath = path.join(TMP_HOME, ".agenthub.json");
|
||||
|
||||
beforeEach(() => fs.rmSync(infoPath, { force: true }));
|
||||
after(() => fs.rmSync(infoPath, { force: true }));
|
||||
|
||||
@@ -253,20 +253,5 @@ describe("plugin marketplace", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("declares an MCP config that exists and resolves through CLAUDE_PLUGIN_ROOT", () => {
|
||||
const mcpFile = path.join(REPO_ROOT, manifest.mcpServers);
|
||||
assert.ok(fs.existsSync(mcpFile), `${manifest.mcpServers} does not exist`);
|
||||
const mcp = readJson(mcpFile);
|
||||
const args = JSON.stringify(mcp.mcpServers);
|
||||
assert.match(args, /\$\{CLAUDE_PLUGIN_ROOT\}\/mcp\/build\/index\.js/);
|
||||
});
|
||||
|
||||
it("ships the built MCP server the config points at", () => {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(REPO_ROOT, "mcp", "build", "index.js")),
|
||||
"mcp/build/index.js must be committed — plugin MCP servers start before any bootstrap can build them"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ function freshModule(dataDir) {
|
||||
}
|
||||
|
||||
function writeDiscovery(servers) {
|
||||
const infoPath = path.join(CLAUDE_HOME, ".agent-dashboard.json");
|
||||
const infoPath = path.join(CLAUDE_HOME, ".agenthub.json");
|
||||
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
|
||||
const recent = servers.reduce((a, b) =>
|
||||
Date.parse(b.startedAt) > Date.parse(a.startedAt) ? b : a
|
||||
|
||||
@@ -179,6 +179,8 @@ describe("GET /api/workflows/skill-usage", () => {
|
||||
ev("PostToolUse", "Skill", { tool_input: { skill: "agenthub:ship-feature" } });
|
||||
}
|
||||
ev("PreToolUse", "Skill", { tool_input: { skill: "daily-report" } });
|
||||
// AgentHub no longer ships an MCP server, but events recorded while it did
|
||||
// are still in the database, and analytics must keep attributing them.
|
||||
ev("PreToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
|
||||
ev("PostToolUse", "mcp__plugin_agenthub_agenthub__dashboard_health_check", {});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user