import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, rm, writeFile, chmod, readFile, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runPostInstall, hashScript } from '../../src/utils/postinstall.js'; describe('postinstall executor', () => { let tmp: string; beforeEach(async () => { tmp = await mkdtemp(join(tmpdir(), 'mcpctl-postinstall-')); }); afterEach(async () => { await rm(tmp, { recursive: true, force: true }); }); describe('hashScript', () => { it('returns deterministic sha256-prefixed hash', () => { expect(hashScript('hello')).toMatch(/^sha256:[0-9a-f]{64}$/); expect(hashScript('hello')).toBe(hashScript('hello')); expect(hashScript('hello')).not.toBe(hashScript('hellp')); }); }); describe('runPostInstall — success path', () => { it('runs a passing script and returns exit 0 + script hash', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); const scriptPath = join(installDir, 'install.sh'); await writeFile(scriptPath, '#!/bin/sh\necho hello-stdout\necho hello-stderr 1>&2\nexit 0\n'); await chmod(scriptPath, 0o755); const result = await runPostInstall({ installDir, scriptPath: 'install.sh', skillName: 'test-skill', semver: '0.1.0', logsDir: join(tmp, 'logs'), }); expect(result.exitCode).toBe(0); expect(result.timedOut).toBe(false); expect(result.stdoutTail).toContain('hello-stdout'); expect(result.stderrTail).toContain('hello-stderr'); expect(result.scriptHash).toMatch(/^sha256:/); }); it('passes curated env (MCPCTL_SKILL_NAME, _VERSION, _DIR, _PROJECT)', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); const scriptPath = join(installDir, 'install.sh'); // Write env vars to a file we can read back. const outFile = join(tmp, 'env-dump.txt'); await writeFile(scriptPath, `#!/bin/sh echo "name=$MCPCTL_SKILL_NAME" > ${JSON.stringify(outFile)} echo "version=$MCPCTL_SKILL_VERSION" >> ${JSON.stringify(outFile)} echo "dir=$MCPCTL_SKILL_DIR" >> ${JSON.stringify(outFile)} echo "project=$MCPCTL_PROJECT" >> ${JSON.stringify(outFile)} `); await chmod(scriptPath, 0o755); const result = await runPostInstall({ installDir, scriptPath: 'install.sh', skillName: 'env-test', semver: '1.2.3', projectName: 'demo', logsDir: join(tmp, 'logs'), }); expect(result.exitCode).toBe(0); const dumped = await readFile(outFile, 'utf-8'); expect(dumped).toContain('name=env-test'); expect(dumped).toContain('version=1.2.3'); expect(dumped).toContain('dir=' + installDir); expect(dumped).toContain('project=demo'); }); it('chmods 0644 scripts to executable before spawn', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); const scriptPath = join(installDir, 'install.sh'); await writeFile(scriptPath, '#!/bin/sh\nexit 0\n'); await chmod(scriptPath, 0o644); // not executable const result = await runPostInstall({ installDir, scriptPath: 'install.sh', skillName: 't', semver: '0.1.0', logsDir: join(tmp, 'logs'), }); expect(result.exitCode).toBe(0); }); }); describe('runPostInstall — failure paths', () => { it('captures non-zero exit code and returns it', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); const scriptPath = join(installDir, 'fail.sh'); await writeFile(scriptPath, '#!/bin/sh\necho oops 1>&2\nexit 7\n'); await chmod(scriptPath, 0o755); const result = await runPostInstall({ installDir, scriptPath: 'fail.sh', skillName: 't', semver: '0.1.0', logsDir: join(tmp, 'logs'), }); expect(result.exitCode).toBe(7); expect(result.timedOut).toBe(false); expect(result.stderrTail).toContain('oops'); }); it('honors timeoutSec — kills via SIGTERM and reports timedOut=true', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); const scriptPath = join(installDir, 'hang.sh'); // `exec` so SIGTERM hits sleep directly — without it /bin/sh // catches the signal but the orphaned sleep keeps the streams // open until SIGKILL; the test then has to wait for the 2s grace // window before we force-kill, which is fine but flakier. await writeFile(scriptPath, '#!/bin/sh\nexec sleep 30\n'); await chmod(scriptPath, 0o755); const start = Date.now(); const result = await runPostInstall({ installDir, scriptPath: 'hang.sh', skillName: 't', semver: '0.1.0', timeoutSec: 1, logsDir: join(tmp, 'logs'), }); const elapsed = Date.now() - start; expect(result.timedOut).toBe(true); // 1s timeout + up to 2s grace before SIGKILL. expect(elapsed).toBeLessThan(5000); expect(elapsed).toBeGreaterThanOrEqual(1000); }, 15_000); it('rejects path-escape attempts', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); await expect(runPostInstall({ installDir, scriptPath: '../escape.sh', skillName: 't', semver: '0.1.0', logsDir: join(tmp, 'logs'), })).rejects.toThrow(/escapes skill dir/); }); it('throws when the script does not exist', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); await expect(runPostInstall({ installDir, scriptPath: 'missing.sh', skillName: 't', semver: '0.1.0', logsDir: join(tmp, 'logs'), })).rejects.toThrow(); }); }); describe('runPostInstall — install log', () => { it('writes stdout + stderr + exit summary to logsDir/install.log', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); const scriptPath = join(installDir, 'install.sh'); await writeFile(scriptPath, '#!/bin/sh\necho hello\nexit 0\n'); await chmod(scriptPath, 0o755); const logsDir = join(tmp, 'logs'); await runPostInstall({ installDir, scriptPath: 'install.sh', skillName: 'log-test', semver: '0.1.0', logsDir, }); const log = await readFile(join(logsDir, 'install.log'), 'utf-8'); expect(log).toContain('log-test@0.1.0'); expect(log).toContain('hello'); expect(log).toContain('exit 0'); }); it('appends across runs without losing prior history', async () => { const installDir = join(tmp, 'skill'); await mkdir(installDir, { recursive: true }); const scriptPath = join(installDir, 'install.sh'); await writeFile(scriptPath, '#!/bin/sh\necho run\nexit 0\n'); await chmod(scriptPath, 0o755); const logsDir = join(tmp, 'logs'); const input = { installDir, scriptPath: 'install.sh', skillName: 't', semver: '0.1.0', logsDir, }; await runPostInstall(input); await runPostInstall(input); const log = await readFile(join(logsDir, 'install.log'), 'utf-8'); // Two run headers separated by `===`. const headers = (log.match(/=== /g) ?? []).length; expect(headers).toBeGreaterThanOrEqual(2); }); }); });