feat(cli): postInstall executor for mcpctl skills sync

Closes the biggest deferred item from PR-5. metadata.postInstall
scripts now actually run when their hash changes, with audit emission
back to mcpd.

Trust model unchanged from the corporate-appliance design: mcpd is
the source of truth, content is reviewed at publish time, the client
just runs. No sandbox, no signing, no consent prompts. The controls
that matter are already on the publishing side (RBAC, audit, reviewer
queue).

What we DO provide is ops hygiene:

- Hard timeout (default 60 s; per-skill override via
  metadata.postInstallTimeoutSec). SIGTERM at the deadline, SIGKILL
  after a 2 s grace.
- Hash pinning. The script's sha256 is recorded in
  ~/.mcpctl/skills-state.json. Re-syncs of an unchanged script are a
  cheap no-op. A re-published "same version, fixed script" still
  triggers re-execution because its hash changed.
- Curated env. PATH/HOME/USER/SHELL inherited; everything else dropped.
  MCPCTL_SKILL_NAME / _VERSION / _DIR / _PROJECT injected so scripts
  have stable context.
- Per-skill install log under ~/.mcpctl/skills/<name>/install.log.
  Bounded at 5 × 256 KB; old entries truncated from the front.
- Audit event back to mcpd (POST /api/v1/audit-events,
  eventKind=skill_postinstall) on every run, including hostname so
  admins can see fleet rollout state. Best-effort — failures are
  warned but never block the sync.
- Path-escape rejection. metadata.postInstall must resolve inside the
  skill bundle; relative paths that try to climb out are refused.
- Auto-chmod 0755 on the script before spawn. Some upstreams ship 0644
  + a shebang and expect a shell to handle it; we always spawn the
  path directly so we need +x.

Failure semantics: on timeout or non-zero exit, the recorded
postInstallHash is NOT updated. Next sync retries. Other skills in
the same sync run continue regardless — postInstall errors are
scoped, not fatal.

## Files

### Added
- src/cli/src/utils/postinstall.ts (~200 LOC)
- src/cli/tests/utils/postinstall.test.ts (~190 LOC, 10 tests covering
  success, env vars, chmod, non-zero exit, timeout via exec sleep,
  path-escape, missing script, log file shape + append-across-runs)

### Edited
- src/cli/src/commands/skills.ts: applyOne now invokes runPostInstall
  + emitPostInstallAudit when metadata.postInstall is set and the
  script hash differs from prior state. New SyncResult fields:
  postInstallsRan, postInstallsSkipped. Summary line surfaces
  "N postInstall ran". --skip-postinstall flag now actually does what
  it says.

## Verification

163 test files / 2171 tests green (up from 2161).

End-to-end on a real machine (after this PR ships and a skill with
metadata.postInstall is published):

```
mcpctl skills sync
# → mcpctl: 1 installed, 1 postInstall ran
cat ~/.mcpctl/skills/<name>/install.log    # see stdout/stderr
mcpctl skills sync                          # idempotent — skipped
```

If the same skill is republished with a fixed script:

```
mcpctl skills sync
# → mcpctl: 1 updated, 1 postInstall ran  (hash changed → rerun)
```

If the script fails (exit 7):

```
mcpctl skills sync
# → mcpctl: 1 updated, 1 errors
mcpctl skills sync   # state.postInstallHash NOT updated → retries
```

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-05-07 19:05:42 +01:00
parent e6cd73543a
commit d60ad52018
3 changed files with 595 additions and 6 deletions

View File

@@ -16,6 +16,11 @@ import {
removeSkillAtomic, removeSkillAtomic,
type SkillBody, type SkillBody,
} from '../utils/skills-disk.js'; } from '../utils/skills-disk.js';
import {
runPostInstall,
emitPostInstallAudit,
hashScript,
} from '../utils/postinstall.js';
import { ApiError } from '../api-client.js'; import { ApiError } from '../api-client.js';
/** /**
@@ -50,6 +55,19 @@ interface FullSkill {
agentId: string | null; agentId: string | null;
} }
/**
* Shape of `metadata` we care about at sync time. Validated server-side
* by SkillMetadataSchema (PR-3); we re-narrow here for the fields the
* sync acts on, keeping the rest opaque so future additions don't
* require a CLI change.
*/
interface SyncedSkillMetadata {
postInstall?: unknown;
postInstallTimeoutSec?: unknown;
// hooks / mcpServers materialisation lives in a follow-up — see TODO
// in applyOne where we'd call them after the file install.
}
export interface SyncOpts { export interface SyncOpts {
/** Project name override; otherwise marker walk-up + fall back to globals-only. */ /** Project name override; otherwise marker walk-up + fall back to globals-only. */
project?: string; project?: string;
@@ -72,6 +90,8 @@ export interface SyncResult {
skipped: string[]; skipped: string[];
removed: string[]; removed: string[];
preserved: string[]; // skills with local edits we left alone preserved: string[]; // skills with local edits we left alone
postInstallsRan: string[]; // skills whose postInstall executed in this sync
postInstallsSkipped: string[]; // skills with postInstall but unchanged hash → no rerun
errors: Array<{ skill: string; error: string }>; errors: Array<{ skill: string; error: string }>;
exitCode: 0 | 1 | 2; exitCode: 0 | 1 | 2;
} }
@@ -95,6 +115,8 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
skipped: [], skipped: [],
removed: [], removed: [],
preserved: [], preserved: [],
postInstallsRan: [],
postInstallsSkipped: [],
errors: [], errors: [],
exitCode: 0, exitCode: 0,
}; };
@@ -210,18 +232,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
} }
// 8. Summary. // 8. Summary.
if (!opts.quiet || result.errors.length > 0 || result.installed.length > 0 || result.updated.length > 0 || result.removed.length > 0) { if (!opts.quiet || result.errors.length > 0 || result.installed.length > 0 || result.updated.length > 0 || result.removed.length > 0 || result.postInstallsRan.length > 0) {
const parts: string[] = []; const parts: string[] = [];
if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); if (result.installed.length) parts.push(`${String(result.installed.length)} installed`);
if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); if (result.updated.length) parts.push(`${String(result.updated.length)} updated`);
if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`);
if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); if (result.removed.length) parts.push(`${String(result.removed.length)} removed`);
if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`);
if (result.postInstallsRan.length) parts.push(`${String(result.postInstallsRan.length)} postInstall ran`);
if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); if (result.errors.length) parts.push(`${String(result.errors.length)} errors`);
if (parts.length === 0) parts.push('no changes'); if (parts.length === 0) parts.push('no changes');
if (!opts.quiet) { if (!opts.quiet) {
log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`);
} else if (result.installed.length || result.updated.length || result.removed.length || result.errors.length) { } else if (result.installed.length || result.updated.length || result.removed.length || result.postInstallsRan.length || result.errors.length) {
// Quiet mode: only emit a single line if something actually happened. // Quiet mode: only emit a single line if something actually happened.
warn(`mcpctl: ${parts.join(', ')}`); warn(`mcpctl: ${parts.join(', ')}`);
} }
@@ -255,6 +278,70 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
}; };
const fileStates = await installSkillAtomic(targetDir, body); const fileStates = await installSkillAtomic(targetDir, body);
// ── postInstall: run metadata.postInstall when present ──
// Hash-pinned: only execute when the script's sha256 differs from
// what state recorded. Failures DO NOT update the recorded hash so
// the next sync retries. Other skills continue regardless.
let postInstallHash: string | null = prior?.postInstallHash ?? null;
const meta = (full.metadata ?? {}) as SyncedSkillMetadata;
if (
!opts.skipPostInstall &&
typeof meta.postInstall === 'string' &&
meta.postInstall.length > 0
) {
const scriptRel = meta.postInstall;
const scriptContent = (full.files ?? {})[scriptRel];
if (typeof scriptContent !== 'string') {
warn(`mcpctl: skill '${v.name}' postInstall references '${scriptRel}' which is not in files{}; skipping`);
} else {
const newHash = hashScript(scriptContent);
const hashChanged = newHash !== prior?.postInstallHash;
if (!hashChanged) {
result.postInstallsSkipped.push(v.name);
postInstallHash = newHash;
} else {
try {
const timeoutSec = typeof meta.postInstallTimeoutSec === 'number' ? meta.postInstallTimeoutSec : undefined;
const piInput = {
installDir: targetDir,
scriptPath: scriptRel,
skillName: v.name,
semver: v.semver,
projectName: projectName ?? undefined,
timeoutSec,
logsDir: join(homedir(), '.mcpctl', 'skills', v.name),
};
const installResult = await runPostInstall(piInput);
// Best-effort audit. Don't await; mcpd slowness shouldn't slow sync.
void emitPostInstallAudit(client, piInput, installResult, (m) => warn(m));
if (installResult.timedOut) {
result.errors.push({
skill: v.name,
error: `postInstall timed out after ${String(installResult.durationMs)}ms; rerun next sync`,
});
// hash NOT updated → retry on next sync
} else if (installResult.exitCode !== 0) {
const tail = installResult.stderrTail.trim() || installResult.stdoutTail.trim() || `exit ${String(installResult.exitCode)}`;
result.errors.push({
skill: v.name,
error: `postInstall failed (exit ${String(installResult.exitCode)}): ${tail.slice(-200)}`,
});
// hash NOT updated → retry on next sync
} else {
postInstallHash = installResult.scriptHash;
result.postInstallsRan.push(v.name);
}
} catch (err: unknown) {
result.errors.push({
skill: v.name,
error: `postInstall error: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
}
}
const newState: SkillState = { const newState: SkillState = {
id: v.id, id: v.id,
semver: v.semver, semver: v.semver,
@@ -262,10 +349,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
scope: v.scope, scope: v.scope,
installDir: targetDir, installDir: targetDir,
files: fileStates, files: fileStates,
// Tier-2 fields — postInstall execution is deferred to a follow-up postInstallHash,
// PR. For now we record the hash so we can detect script changes
// when execution lands.
postInstallHash: null,
lastSyncedAt: new Date().toISOString(), lastSyncedAt: new Date().toISOString(),
}; };
state.skills[v.name] = newState; state.skills[v.name] = newState;

View File

@@ -0,0 +1,282 @@
/**
* postInstall executor for `mcpctl skills sync`.
*
* Trust model: mcpctl runs scripts that mcpd has served. mcpd is the
* corporate source of truth — content is reviewed at publish time. We
* do NOT sandbox or signature-check on the client. The controls that
* matter live on the publishing side (RBAC, audit, reviewer queue).
*
* What we DO provide is ops hygiene:
* - Hard timeout (default 60 s, per-skill override via
* `metadata.postInstallTimeoutSec`). Stops a runaway script from
* wedging Claude startup forever.
* - Hash-pinning: the script's sha256 is recorded in the skills state
* file so the next sync skips re-execution unless the hash changed.
* Saves churn; catches "the same skill at the same semver was
* re-published with a fixed script".
* - Curated env: MCPCTL_SKILL_NAME / _VERSION / _DIR / _PROJECT plus
* inherited PATH / HOME / USER / SHELL. Cron-style minimal env so
* scripts behave the same on every machine.
* - Per-skill install log under ~/.mcpctl/skills/<name>/install.log
* (rotated to keep the last 5 runs). Standard sysadmin reflex.
* - Audit event back to mcpd on every run. So mcpd's audit pipeline
* has both sides of the timeline (publish + per-machine execution).
*
* Failure semantics: a non-zero exit, a hang past the timeout, or a
* spawn error is treated as a failed sync of THIS skill. The state
* file's postInstallHash is NOT updated on failure, so the next sync
* will retry. Other skills in the same sync run continue regardless.
*/
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { hostname } from 'node:os';
import { setTimeout as delay } from 'node:timers/promises';
import type { ApiClient } from '../api-client.js';
export interface PostInstallInput {
/** Full path of the materialised skill directory. The script path is resolved relative to this. */
installDir: string;
/** metadata.postInstall — relative path inside the skill bundle. */
scriptPath: string;
/** Name of the skill. Surfaces in audit + env + log path. */
skillName: string;
/** Skill version. Audit + env. */
semver: string;
/** Project name when the skill is project-scoped, else undefined. */
projectName?: string | undefined;
/** Per-skill override for the 60-s default. */
timeoutSec?: number | undefined;
/** Where to put the rolling install.log. Default: ~/.mcpctl/skills/<name>/install.log. */
logsDir: string;
}
export interface PostInstallResult {
exitCode: number | null;
durationMs: number;
scriptHash: string;
timedOut: boolean;
signal: NodeJS.Signals | null;
stdoutTail: string;
stderrTail: string;
}
const DEFAULT_TIMEOUT_SEC = 60;
const TAIL_BYTES = 4 * 1024;
const MAX_LOG_BYTES = 256 * 1024;
/**
* Compute the sha256 of a script — used as the "have I already run this
* version?" key in the skills state file. Caller passes the raw script
* bytes; this just wraps the hash routine to stay consistent with the
* `'sha256:'`-prefixed format used elsewhere (skills-state.ts).
*/
export function hashScript(content: string | Buffer): string {
const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
return 'sha256:' + createHash('sha256').update(buf).digest('hex');
}
/**
* Run the post-install script. Returns a result regardless of success
* or failure — caller inspects `exitCode`/`timedOut` to decide.
*
* Path validation: the resolved script path must remain inside
* `installDir`. A skill that tries to point postInstall at
* `../../../../etc/passwd-like` is rejected as a failed run, not
* silently ignored.
*/
export async function runPostInstall(input: PostInstallInput): Promise<PostInstallResult> {
const start = Date.now();
const timeoutMs = (input.timeoutSec ?? DEFAULT_TIMEOUT_SEC) * 1000;
const fullPath = resolve(input.installDir, input.scriptPath);
// Defence in depth: the install dir is server-published content, but
// a server with skill-write RBAC could still cause mischief. The
// check makes our intent explicit: scripts may only live inside the
// skill bundle.
const installDirResolved = resolve(input.installDir);
if (!fullPath.startsWith(installDirResolved + '/') && fullPath !== installDirResolved) {
throw new Error(
`postInstall path '${input.scriptPath}' escapes skill dir`,
);
}
// Read script bytes for hashing (and to fail-fast if missing).
const scriptBytes = await readFile(fullPath);
const scriptHash = hashScript(scriptBytes);
// Curated env. Cron-style minimum: keep PATH so the script can find
// git/curl/python; keep HOME/USER/SHELL so scripts that touch dotfiles
// work; drop everything else.
const env: Record<string, string> = {
PATH: process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin',
HOME: process.env['HOME'] ?? '',
USER: process.env['USER'] ?? '',
SHELL: process.env['SHELL'] ?? '/bin/sh',
LANG: process.env['LANG'] ?? 'C.UTF-8',
TERM: process.env['TERM'] ?? 'dumb',
MCPCTL_SKILL_NAME: input.skillName,
MCPCTL_SKILL_VERSION: input.semver,
MCPCTL_SKILL_DIR: installDirResolved,
};
if (input.projectName) env['MCPCTL_PROJECT'] = input.projectName;
// Make sure the script is executable. Some upstreams ship with mode
// 0644 — if shebang exists, we can fall through to the interpreter;
// otherwise spawn will EACCES.
await ensureExecutable(fullPath, scriptBytes);
await mkdir(input.logsDir, { recursive: true });
const logPath = join(input.logsDir, 'install.log');
// Rolling-append. Keep ~256 KB; old entries get truncated. The tail
// returned to the caller is the last few KB regardless.
const logHeader = `\n=== ${new Date().toISOString()} ${input.skillName}@${input.semver} ===\n`;
// Cast through Buffer<ArrayBufferLike> — Node's typings split Buffer
// into Buffer<ArrayBuffer> (from .alloc) and Buffer<ArrayBufferLike>
// (from .subarray), which exactOptionalPropertyTypes refuses to
// bridge. Explicit `Buffer` annotation widens to the union.
let stdoutBuf: Buffer = Buffer.alloc(0);
let stderrBuf: Buffer = Buffer.alloc(0);
let timedOut = false;
const child = spawn(fullPath, [], {
cwd: installDirResolved,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (chunk: Buffer) => {
stdoutBuf = appendCapped(stdoutBuf, chunk);
});
child.stderr.on('data', (chunk: Buffer) => {
stderrBuf = appendCapped(stderrBuf, chunk);
});
// Hard timeout via SIGTERM, then SIGKILL after 2 s grace.
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
void (async () => {
await delay(2000);
if (child.exitCode === null) child.kill('SIGKILL');
})();
}, timeoutMs);
const { exitCode, signal } = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>((resolveProm) => {
child.on('close', (code, sig) => resolveProm({ exitCode: code, signal: sig }));
child.on('error', () => resolveProm({ exitCode: null, signal: null }));
});
clearTimeout(timer);
const durationMs = Date.now() - start;
const stdoutText = stdoutBuf.toString('utf-8');
const stderrText = stderrBuf.toString('utf-8');
// Append to the install log, truncating from the front if oversize.
const trailer = `\n--- exit ${exitCode === null ? '?' : String(exitCode)}${signal ? ` (${signal})` : ''} in ${String(durationMs)}ms${timedOut ? ' [TIMEOUT]' : ''} ---\n`;
const fullEntry = logHeader + 'STDOUT:\n' + stdoutText + '\nSTDERR:\n' + stderrText + trailer;
await appendBoundedLog(logPath, fullEntry);
return {
exitCode,
durationMs,
scriptHash,
timedOut,
signal,
stdoutTail: tailString(stdoutText, TAIL_BYTES),
stderrTail: tailString(stderrText, TAIL_BYTES),
};
}
/**
* Best-effort audit emission — POSTs a structured event back to mcpd
* so admins have fleet visibility. Failures are warned via the
* provided logger but never thrown; the audit log is supplementary,
* not load-bearing for sync correctness.
*
* The event includes machine fingerprint (hostname) so the operator
* can tell which dev box ran the script — useful when triaging a
* misbehaving update.
*/
export async function emitPostInstallAudit(
client: ApiClient,
input: PostInstallInput,
result: PostInstallResult,
warn: (msg: string) => void = () => {},
): Promise<void> {
try {
await client.post('/api/v1/audit-events', {
eventKind: 'skill_postinstall',
source: 'mcpctl-cli',
verified: false,
payload: {
skillName: input.skillName,
skillVersion: input.semver,
projectName: input.projectName ?? null,
scriptPath: input.scriptPath,
scriptHash: result.scriptHash,
exitCode: result.exitCode,
durationMs: result.durationMs,
timedOut: result.timedOut,
signal: result.signal,
machine: hostname(),
},
});
} catch (err) {
warn(`mcpctl: failed to emit postInstall audit event: ${err instanceof Error ? err.message : String(err)}`);
}
}
// ── internals ──
function appendCapped(buf: Buffer, chunk: Buffer): Buffer {
// Keep up to MAX_LOG_BYTES per stream; drop oldest bytes if over.
if (buf.length + chunk.length <= MAX_LOG_BYTES) {
return Buffer.concat([buf, chunk]);
}
const merged = Buffer.concat([buf, chunk]);
// Buffer.from(...) here keeps Node's typing happy under
// exactOptionalPropertyTypes — `subarray` on Buffer returns a
// Buffer<ArrayBufferLike> which TS won't widen to the input type.
return Buffer.from(merged.subarray(merged.length - MAX_LOG_BYTES));
}
function tailString(s: string, bytes: number): string {
if (s.length <= bytes) return s;
return '…' + s.slice(s.length - bytes + 1);
}
async function ensureExecutable(path: string, bytes: Buffer): Promise<void> {
try {
const st = await stat(path);
// Owner execute bit. Skip if it's set already.
if ((st.mode & 0o100) !== 0) return;
} catch {
return; // stat failed — let the spawn surface the real error
}
// Has shebang? Fine — many shells will still execute even without +x
// when invoked as `<interpreter> <path>`, but we always spawn the
// path directly so we need +x. Set 0755.
void bytes; // (kept around in case we want to inspect shebang later)
const { chmod } = await import('node:fs/promises');
await chmod(path, 0o755);
}
async function appendBoundedLog(path: string, entry: string): Promise<void> {
const max = 5 * MAX_LOG_BYTES;
let existing = '';
try {
existing = await readFile(path, 'utf-8');
} catch (err: unknown) {
if (typeof err !== 'object' || err === null || (err as { code?: string }).code !== 'ENOENT') throw err;
}
const combined = existing + entry;
// Keep last `max` bytes.
const trimmed = combined.length > max ? '…[truncated]…\n' + combined.slice(combined.length - max) : combined;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, trimmed, 'utf-8');
}

View File

@@ -0,0 +1,223 @@
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);
});
});
});