moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
import { afterEach, describe, expect, it } from 'vitest';
import { existsSync } from 'node:fs';
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { install, MCP_SERVER_NAME } from '../src/install.js';
import { resolvePaths } from '../src/paths.js';
interface TestEnv {
home: string;
mcpEntry: string;
}
async function bootstrap(initial?: Record<string, unknown>): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-install-'));
const mcpEntry = join(home, 'memory-mcp.js');
await writeFile(mcpEntry, 'export {};\n', 'utf-8');
if (initial !== undefined) {
const paths = resolvePaths({ home, platform: 'linux' });
await mkdir(paths.claudeConfigDir, { recursive: true });
await writeFile(paths.configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
}
return { home, mcpEntry };
}
function installOpts(env: TestEnv, mcpEntry = env.mcpEntry) {
return {
home: env.home,
platform: 'linux' as const,
mcpEntry,
};
}
describe('install (claude-desktop)', () => {
const homes: string[] = [];
afterEach(async () => {
for (const home of homes.splice(0)) {
await rm(home, { recursive: true, force: true });
}
});
it('creates a config and ownership pointer on a fresh install', async () => {
const env = await bootstrap();
homes.push(env.home);
const result = await install({
...installOpts(env),
cliPath: ' /opt/hive-mind-cli/dist/index.js ',
});
const config = JSON.parse(await readFile(result.paths.configPath, 'utf-8')) as {
mcpServers: Record<string, { command: string; args: string[] }>;
};
const pointer = JSON.parse(await readFile(result.pointerPath, 'utf-8')) as Record<string, unknown>;
expect(result.createdByUs).toBe(true);
expect(result.backupPath).toBeNull();
expect(config.mcpServers[MCP_SERVER_NAME]).toEqual({
command: process.env.WAGGLE_HOOK_NODE_PATH?.trim() || process.execPath,
args: [env.mcpEntry],
});
expect(Object.keys(config.mcpServers[MCP_SERVER_NAME])).toEqual(['command', 'args']);
expect(pointer['created_by_us']).toBe(true);
expect(pointer['settings_backup']).toBeNull();
expect(pointer['hooks_dir']).toBeNull();
expect(pointer['installed_hooks']).toEqual(['mcp:waggle-memory']);
expect(pointer['cli_path']).toBe('/opt/hive-mind-cli/dist/index.js');
});
it('backs up a pre-existing config byte-identically and preserves other servers', async () => {
const existingServer = {
command: 'python',
args: ['server.py'],
env: { KEEP_ME: 'yes' },
};
const env = await bootstrap({
theme: 'dark',
mcpServers: { userServer: existingServer },
});
homes.push(env.home);
const paths = resolvePaths({ home: env.home, platform: 'linux' });
const originalBytes = await readFile(paths.configPath);
const result = await install(installOpts(env));
const config = JSON.parse(await readFile(paths.configPath, 'utf-8')) as {
theme: string;
mcpServers: Record<string, unknown>;
};
expect(result.createdByUs).toBe(false);
expect(result.backupPath).not.toBeNull();
expect(await readFile(result.backupPath as string)).toEqual(originalBytes);
expect(config.theme).toBe('dark');
expect(config.mcpServers['userServer']).toEqual(existingServer);
expect(config.mcpServers[MCP_SERVER_NAME]).toBeDefined();
});
it("refuses to clobber a foreign 'waggle-memory' entry without a pointer", async () => {
const foreignEntry = { command: 'node', args: ['/user/server.js'] };
const env = await bootstrap({ mcpServers: { [MCP_SERVER_NAME]: foreignEntry } });
homes.push(env.home);
await expect(install(installOpts(env))).rejects.toThrow(
/was not installed by this tool; remove or rename it first/,
);
const paths = resolvePaths({ home: env.home, platform: 'linux' });
const config = JSON.parse(await readFile(paths.configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
expect(config.mcpServers[MCP_SERVER_NAME]).toEqual(foreignEntry);
expect(existsSync(paths.pointerPath)).toBe(false);
});
it('replaces its entry on reinstall without replacing the original backup or ownership', async () => {
const env = await bootstrap({ mcpServers: { userServer: { command: 'user', args: [] } } });
homes.push(env.home);
const first = await install(installOpts(env));
const firstPointer = JSON.parse(await readFile(first.pointerPath, 'utf-8')) as Record<string, unknown>;
const replacementEntry = join(env.home, 'memory-mcp-v2.js');
await writeFile(replacementEntry, 'export const version = 2;\n', 'utf-8');
const second = await install(installOpts(env, replacementEntry));
const secondPointer = JSON.parse(await readFile(second.pointerPath, 'utf-8')) as Record<string, unknown>;
const config = JSON.parse(await readFile(second.paths.configPath, 'utf-8')) as {
mcpServers: Record<string, { args: string[] }>;
};
const backups = (await readdir(second.paths.claudeConfigDir))
.filter((name) => name.includes('hive-mind-backup'));
expect(config.mcpServers[MCP_SERVER_NAME].args).toEqual([replacementEntry]);
expect(Object.keys(config.mcpServers).filter((name) => name === MCP_SERVER_NAME)).toHaveLength(1);
expect(second.backupPath).toBe(first.backupPath);
expect(secondPointer['settings_backup']).toBe(firstPointer['settings_backup']);
expect(secondPointer['created_by_us']).toBe(firstPointer['created_by_us']);
expect(backups).toHaveLength(1);
});
it('throws when the MCP entry cannot be resolved', async () => {
const env = await bootstrap();
homes.push(env.home);
await expect(install({
home: env.home,
platform: 'linux',
mcpEntry: ' ',
})).rejects.toThrow(/cannot resolve waggle-memory-mcp\/dist\/index\.js/);
});
});

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolvePaths } from '../src/paths.js';
describe('resolvePaths (claude-desktop)', () => {
const homes: string[] = [];
afterEach(async () => {
for (const home of homes.splice(0)) {
await rm(home, { recursive: true, force: true });
}
});
async function tempHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-paths-'));
homes.push(home);
return home;
}
it('uses the Windows Claude Desktop config path', async () => {
const home = await tempHome();
const paths = resolvePaths({ home, platform: 'win32' });
expect(paths.configPath).toBe(join(
home,
'AppData',
'Roaming',
'Claude',
'claude_desktop_config.json',
));
});
it('uses the macOS Claude Desktop config path', async () => {
const home = await tempHome();
const paths = resolvePaths({ home, platform: 'darwin' });
expect(paths.configPath).toBe(join(
home,
'Library',
'Application Support',
'Claude',
'claude_desktop_config.json',
));
});
it('uses the Linux Claude Desktop config path', async () => {
const home = await tempHome();
const paths = resolvePaths({ home, platform: 'linux' });
expect(paths.configPath).toBe(join(
home,
'.config',
'Claude',
'claude_desktop_config.json',
));
});
it('lets configDir override the platform default', async () => {
const home = await tempHome();
const configDir = join(home, 'custom-claude-config');
const paths = resolvePaths({ home, platform: 'linux', configDir });
expect(paths.claudeConfigDir).toBe(configDir);
expect(paths.configPath).toBe(join(configDir, 'claude_desktop_config.json'));
});
it('uses one platform-neutral home-relative pointer path', async () => {
const home = await tempHome();
for (const platform of ['win32', 'darwin', 'linux'] as const) {
expect(resolvePaths({ home, platform }).pointerPath).toBe(join(
home,
'.waggle',
'claude-desktop',
'hive-mind-install.json',
));
}
});
});

View File

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it } from 'vitest';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { install, MCP_SERVER_NAME } from '../src/install.js';
import { resolvePaths } from '../src/paths.js';
import { uninstall } from '../src/uninstall.js';
interface TestEnv {
home: string;
mcpEntry: string;
}
async function bootstrap(initial?: Record<string, unknown>): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-uninstall-'));
const mcpEntry = join(home, 'memory-mcp.js');
await writeFile(mcpEntry, 'export {};\n', 'utf-8');
if (initial !== undefined) {
const paths = resolvePaths({ home, platform: 'linux' });
await mkdir(paths.claudeConfigDir, { recursive: true });
await writeFile(paths.configPath, JSON.stringify(initial, null, 2) + '\n', 'utf-8');
}
return { home, mcpEntry };
}
function installOpts(env: TestEnv) {
return {
home: env.home,
platform: 'linux' as const,
mcpEntry: env.mcpEntry,
};
}
describe('uninstall (claude-desktop)', () => {
const homes: string[] = [];
afterEach(async () => {
for (const home of homes.splice(0)) {
await rm(home, { recursive: true, force: true });
}
});
it('restores an untouched config byte-identically and removes backup and pointer', async () => {
const env = await bootstrap({
mcpServers: { userServer: { command: 'python', args: ['server.py'] } },
setting: true,
});
homes.push(env.home);
const paths = resolvePaths({ home: env.home, platform: 'linux' });
const before = await readFile(paths.configPath);
const installed = await install(installOpts(env));
const result = await uninstall({ home: env.home, platform: 'linux' });
const after = await readFile(paths.configPath);
expect(after).toEqual(before);
expect(result.surgical).toBe(false);
expect(result.backupRemoved).toBe(true);
expect(result.createdRemoved).toBe(false);
expect(existsSync(installed.backupPath as string)).toBe(false);
expect(existsSync(installed.pointerPath)).toBe(false);
});
it('deletes a config created by the installer', async () => {
const env = await bootstrap();
homes.push(env.home);
const installed = await install(installOpts(env));
const result = await uninstall({ home: env.home, platform: 'linux' });
expect(result.createdRemoved).toBe(true);
expect(result.restoredFrom).toBeNull();
expect(result.surgical).toBe(false);
expect(existsSync(installed.paths.configPath)).toBe(false);
expect(existsSync(installed.pointerPath)).toBe(false);
});
it('surgically removes only waggle-memory when the config changed after install', async () => {
const originalServer = { command: 'python', args: ['original.py'] };
const env = await bootstrap({ mcpServers: { originalServer } });
homes.push(env.home);
const installed = await install(installOpts(env));
const edited = JSON.parse(await readFile(installed.paths.configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
const userAddedServer = { command: 'node', args: ['/user/added.js'] };
edited.mcpServers['userAddedServer'] = userAddedServer;
await writeFile(installed.paths.configPath, JSON.stringify(edited, null, 2) + '\n', 'utf-8');
const result = await uninstall({ home: env.home, platform: 'linux' });
const after = JSON.parse(await readFile(installed.paths.configPath, 'utf-8')) as {
mcpServers: Record<string, unknown>;
};
expect(result.surgical).toBe(true);
expect(result.backupRemoved).toBe(false);
expect(after.mcpServers[MCP_SERVER_NAME]).toBeUndefined();
expect(after.mcpServers['originalServer']).toEqual(originalServer);
expect(after.mcpServers['userAddedServer']).toEqual(userAddedServer);
expect(existsSync(installed.backupPath as string)).toBe(true);
expect(existsSync(installed.pointerPath)).toBe(false);
});
it('throws when the install pointer is missing', async () => {
const env = await bootstrap({});
homes.push(env.home);
await expect(uninstall({ home: env.home, platform: 'linux' }))
.rejects.toThrow(/no install pointer/);
});
});

View File

@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import type { ChildProcess } from 'node:child_process';
import { install, MCP_SERVER_NAME } from '../src/install.js';
import { resolvePaths } from '../src/paths.js';
import { verify } from '../src/verify.js';
interface TestEnv {
home: string;
mcpEntry: string;
}
async function bootstrap(): Promise<TestEnv> {
const home = await mkdtemp(join(tmpdir(), 'hm-claude-desktop-verify-'));
const mcpEntry = join(home, 'memory-mcp.js');
await writeFile(mcpEntry, 'export const ready = true;\n', 'utf-8');
return { home, mcpEntry };
}
async function writeConfig(home: string, config: Record<string, unknown>): Promise<void> {
const paths = resolvePaths({ home, platform: 'linux' });
await mkdir(paths.claudeConfigDir, { recursive: true });
await writeFile(paths.configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
function hangingSpawn(onSpawn: () => void): typeof import('node:child_process').spawn {
return ((_command: string, _args: readonly string[], _options?: unknown) => {
onSpawn();
const child = Object.assign(new EventEmitter(), {
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn(() => true),
}) as unknown as ChildProcess;
return child;
}) as unknown as typeof import('node:child_process').spawn;
}
describe('verify (claude-desktop)', () => {
const homes: string[] = [];
afterEach(async () => {
vi.useRealTimers();
for (const home of homes.splice(0)) {
await rm(home, { recursive: true, force: true });
}
});
it('passes with an installed config, readable entry, valid syntax, and pointer', async () => {
const env = await bootstrap();
homes.push(env.home);
await install({ home: env.home, platform: 'linux', mcpEntry: env.mcpEntry });
const result = await verify({ home: env.home, platform: 'linux' });
expect(result.ok).toBe(true);
expect(result.checks.map((check) => check.name)).toEqual([
'claude_desktop_config.json exists',
'config parses as JSON',
"mcpServers contains 'waggle-memory' entry",
'server entry points at waggle-memory-mcp',
'memory-mcp entry readable on disk',
'memory-mcp entry parses (node --check)',
'install pointer present',
]);
});
it('fails the config existence check when the config is missing', async () => {
const env = await bootstrap();
homes.push(env.home);
const result = await verify({ home: env.home, platform: 'linux' });
expect(result.ok).toBe(false);
expect(result.checks).toEqual([{
name: 'claude_desktop_config.json exists',
ok: false,
detail: resolvePaths({ home: env.home, platform: 'linux' }).configPath,
}]);
});
it("fails the mcpServers contains 'waggle-memory' entry check when absent", async () => {
const env = await bootstrap();
homes.push(env.home);
await writeConfig(env.home, { mcpServers: { other: { command: 'node', args: [] } } });
const result = await verify({ home: env.home, platform: 'linux' });
const check = result.checks.find((item) => item.name.includes("contains 'waggle-memory'"));
expect(result.ok).toBe(false);
expect(check?.ok).toBe(false);
});
it('fails the entry readability check when args[0] is missing on disk', async () => {
const env = await bootstrap();
homes.push(env.home);
const missingEntry = join(env.home, 'missing-memory-mcp.js');
await writeConfig(env.home, {
mcpServers: {
[MCP_SERVER_NAME]: { command: process.execPath, args: [missingEntry] },
},
});
const result = await verify({ home: env.home, platform: 'linux' });
const check = result.checks.find((item) => item.name === 'memory-mcp entry readable on disk');
expect(result.ok).toBe(false);
expect(check?.ok).toBe(false);
expect(check?.detail).toBe(missingEntry);
});
it('times out and kills a hung node --check probe through spawnImpl', async () => {
const env = await bootstrap();
homes.push(env.home);
await install({ home: env.home, platform: 'linux', mcpEntry: env.mcpEntry });
vi.useFakeTimers();
let spawned = false;
const spawnImpl = hangingSpawn(() => { spawned = true; });
const resultPromise = verify({ home: env.home, platform: 'linux', spawnImpl });
await vi.waitFor(() => expect(spawned).toBe(true));
await vi.advanceTimersByTimeAsync(4000);
const result = await resultPromise;
const check = result.checks.find(
(item) => item.name === 'memory-mcp entry parses (node --check)',
);
expect(result.ok).toBe(false);
expect(check?.ok).toBe(false);
expect(check?.detail).toContain('timed out');
});
});