224 lines
7.6 KiB
TypeScript
224 lines
7.6 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|