diff --git a/plugin/scripts/smart-install.js b/plugin/scripts/smart-install.js index 4d2f1a3767..1cd408f861 100644 --- a/plugin/scripts/smart-install.js +++ b/plugin/scripts/smart-install.js @@ -343,54 +343,71 @@ function installUv() { * Add shell alias for claude-mem command */ function installCLI() { - const WORKER_CLI = join(ROOT, 'scripts', 'worker-service.cjs'); const bunPath = getBunPath() || 'bun'; - const aliasLine = `alias claude-mem='${bunPath} "${WORKER_CLI}"'`; - const markerPath = join(ROOT, '.cli-installed'); - // Skip if already installed - if (existsSync(markerPath)) return; + // Use the stable marketplace path so the alias survives plugin upgrades. + // $HOME is expanded at shell invocation time, keeping the line portable. + const STABLE_WORKER_PATH = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs'; + const aliasLine = `alias claude-mem='${bunPath} "${STABLE_WORKER_PATH}"'`; + + // Regex anchored to line start (multiline) — ignores commented-out lines. + const ALIAS_RE = /^alias claude-mem=.*$/m; try { if (IS_WINDOWS) { - // Windows: Add to PATH via PowerShell profile + // Windows: Add/update PowerShell function in profile const profilePath = join(process.env.USERPROFILE || homedir(), 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'); const profileDir = join(process.env.USERPROFILE || homedir(), 'Documents', 'PowerShell'); - const functionDef = `function claude-mem { & "${bunPath}" "${WORKER_CLI}" $args }\n`; + const STABLE_WORKER_PATH_WIN = '$HOME\\.claude\\plugins\\marketplaces\\thedotmack\\plugin\\scripts\\worker-service.cjs'; + const functionDef = `function claude-mem { & "${bunPath}" "${STABLE_WORKER_PATH_WIN}" $args }`; + const PS_FUNC_RE = /^function claude-mem \{[^}]*\}$/m; if (!existsSync(profileDir)) { execSync(`mkdir "${profileDir}"`, { stdio: 'ignore', shell: true }); } const existingContent = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : ''; - if (!existingContent.includes('function claude-mem')) { - writeFileSync(profilePath, existingContent + '\n' + functionDef); + const psMatch = existingContent.match(PS_FUNC_RE); + + if (!psMatch) { + writeFileSync(profilePath, existingContent.trimEnd() + '\n' + functionDef + '\n'); console.error(`✅ PowerShell function added to profile`); console.error(' Restart your terminal to use: claude-mem '); + } else if (psMatch[0] !== functionDef) { + writeFileSync(profilePath, existingContent.replace(PS_FUNC_RE, functionDef)); + console.error(`✅ PowerShell function updated in profile`); } } else { - // Unix: Add alias to shell configs + // Unix: Add/update alias in shell configs const shellConfigs = [ join(homedir(), '.bashrc'), join(homedir(), '.zshrc') ]; for (const config of shellConfigs) { - if (existsSync(config)) { - const content = readFileSync(config, 'utf-8'); - if (!content.includes('alias claude-mem=')) { - writeFileSync(config, content + '\n' + aliasLine + '\n'); - console.error(`✅ Alias added to ${config}`); - } + if (!existsSync(config)) continue; + let content = readFileSync(config, 'utf-8'); + const match = content.match(ALIAS_RE); + + if (!match) { + // Not present — append it. + writeFileSync(config, content.trimEnd() + '\n' + aliasLine + '\n'); + console.error(`✅ Alias added to ${config}`); + continue; } + + if (match[0] === aliasLine) continue; // already canonical + + // Replace existing (potentially stale) alias line with the canonical one. + content = content.replace(ALIAS_RE, aliasLine); + writeFileSync(config, content); + console.error(`✅ Alias updated in ${config}`); } console.error(' Restart your terminal to use: claude-mem '); } - - writeFileSync(markerPath, new Date().toISOString()); } catch (error) { console.error(`⚠️ Could not add shell alias: ${error.message}`); - console.error(` Use directly: ${bunPath} "${WORKER_CLI}" `); + console.error(` Use directly: ${bunPath} "${STABLE_WORKER_PATH}" `); } } diff --git a/tests/smart-install.test.ts b/tests/smart-install.test.ts index d702e66cb8..7126dba2bc 100644 --- a/tests/smart-install.test.ts +++ b/tests/smart-install.test.ts @@ -239,6 +239,113 @@ describe('smart-install stdout JSON output (#1253)', () => { }); }); +/** + * Tests for installCLI() alias stability (version-pinning fix). + * + * installCLI() is not exported, so these tests verify the source-level + * patterns (stable path, regex guard, update logic) and the runtime + * behaviour via a helper that simulates the alias write/update cycle. + */ +describe('smart-install installCLI alias stability', () => { + const SCRIPT_PATH = join(__dirname, '..', 'plugin', 'scripts', 'smart-install.js'); + + /** Extract the installCLI() function body from the source file. */ + function getInstallCLIBody(): string { + const content = readFileSync(SCRIPT_PATH, 'utf-8'); + const funcStart = content.indexOf('function installCLI()'); + const funcEnd = content.indexOf('\n/**', funcStart + 1); + return content.slice(funcStart, funcEnd); + } + + it('should use the stable marketplace path instead of ROOT for the alias', () => { + const content = readFileSync(SCRIPT_PATH, 'utf-8'); + // The alias must point to the stable marketplace path, not a version-pinned ROOT + expect(content).toContain( + "const STABLE_WORKER_PATH = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs'" + ); + // The alias line should be composed from STABLE_WORKER_PATH, not WORKER_CLI/ROOT + expect(content).toContain("const aliasLine = `alias claude-mem='${bunPath} \"${STABLE_WORKER_PATH}\"'`"); + }); + + it('should not compose the alias from ROOT (which is version-pinned)', () => { + const funcBody = getInstallCLIBody(); + expect(funcBody).not.toContain("join(ROOT, 'scripts', 'worker-service.cjs')"); + }); + + it('should use a regex guard instead of simple includes() for alias detection', () => { + const funcBody = getInstallCLIBody(); + expect(funcBody).not.toContain("content.includes('alias claude-mem=')"); + expect(funcBody).toContain('ALIAS_RE'); + expect(funcBody).toContain('content.match(ALIAS_RE)'); + }); + + it('should have logic to update stale aliases, not just skip them', () => { + const funcBody = getInstallCLIBody(); + expect(funcBody).toContain('content.replace(ALIAS_RE, aliasLine)'); + expect(funcBody).toContain('Alias updated in'); + }); + + it('should not use .cli-installed marker for early return', () => { + const funcBody = getInstallCLIBody(); + expect(funcBody).not.toContain('.cli-installed'); + }); + + it('should use $HOME literal in the alias for portability', () => { + const funcBody = getInstallCLIBody(); + expect(funcBody).toContain("'$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs'"); + }); + + // --- Runtime simulation tests --- + // These simulate the regex-based alias write/update logic + // without calling installCLI() directly. + + const ALIAS_RE = /^alias claude-mem=.*$/m; + + it('should add alias when none exists', () => { + const canonical = `alias claude-mem='bun "$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs"'`; + const shellContent = '# some existing config\nexport PATH="/usr/bin:$PATH"\n'; + + const match = shellContent.match(ALIAS_RE); + expect(match).toBeNull(); + + // Simulates the append path + const updated = shellContent.trimEnd() + '\n' + canonical + '\n'; + expect(updated).toContain(canonical); + }); + + it('should not modify config when alias is already canonical', () => { + const canonical = `alias claude-mem='bun "$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs"'`; + const shellContent = `# config\n${canonical}\nexport FOO=bar\n`; + + const match = shellContent.match(ALIAS_RE); + expect(match).not.toBeNull(); + expect(match![0]).toBe(canonical); + }); + + it('should replace a stale version-pinned alias with the canonical one', () => { + const canonical = `alias claude-mem='bun "$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs"'`; + const staleAlias = `alias claude-mem='bun "/Users/someone/.claude/plugins/cache/thedotmack/claude-mem/10.5.2/scripts/worker-service.cjs"'`; + const shellContent = `# config\n${staleAlias}\nexport FOO=bar\n`; + + const match = shellContent.match(ALIAS_RE); + expect(match).not.toBeNull(); + expect(match![0]).not.toBe(canonical); + + const updated = shellContent.replace(ALIAS_RE, canonical); + expect(updated).toContain(canonical); + expect(updated).not.toContain('10.5.2'); + expect(updated).toContain('export FOO=bar'); // other content preserved + }); + + it('should not match commented-out alias lines', () => { + const commentedAlias = `# alias claude-mem='bun "/old/path/worker-service.cjs"'`; + const shellContent = `# config\n${commentedAlias}\nexport FOO=bar\n`; + + const match = shellContent.match(ALIAS_RE); + expect(match).toBeNull(); + }); +}); + /** * Tests for checkBinaryPlatformCompatibility() (#1547). *