/** * Skill setup runner — performs a skill's `setup` block, idempotently. * * Every step is checked first and reported "already satisfied" when it is, * so running setup twice is cheap and changes nothing the second time. A step * that is missing is installed and then checked again; "installed" is only * reported when the re-check passes. * * Safety properties (specs/skill-auto-install.md): * - **Never hangs on a password prompt.** Every child gets a closed stdin; * apt-get runs only as root or through `sudo -n` (passwordless sudo). With * neither, the step fails at once with a message saying what to run by hand. * - **Downloads are verified before they are used.** A file is streamed to a * temp name next to its destination, its size and sha256 are compared with * the manifest, and only then renamed into place. A mismatch deletes the * temp file and fails the step. * - **One setup per skill at a time.** Concurrent calls in this process share * one run; another process (the CLI while the backend installs) waits on a * lock file under `$CREWLY_HOME/skill-setup/locks/`. A lock whose pid is * gone, or that is older than LOCK_STALE_MS, is reclaimed. * - **Everything is logged** to `$CREWLY_HOME/logs/skill-setup/.log`, * including the full output of every install command. * * @module services/skill-setup/skill-setup-runner.service */ import type { RunCommand } from '../harness/harness.types.js'; import { type OsFamily, type SkillSetupManifest, type SkillSetupStep } from './skill-setup-manifest.js'; /** Final state of one step. */ export type StepStatus = 'satisfied' | 'installed' | 'missing' | 'failed' | 'skipped'; /** Progress phases reported while a step runs, plus its final state. */ export type ProgressPhase = 'checking' | 'installing' | 'downloading' | 'waiting' | StepStatus; /** A progress event (also written to the log). */ export interface SetupProgressEvent { skillId: string; /** Step id, or `setup` for whole-run events */ stepId: string; phase: ProgressPhase; message: string; } /** Outcome of one step. */ export interface StepResult { id: string; type: SkillSetupStep['type']; status: StepStatus; message: string; optional: boolean; } /** Outcome of a setup run. */ export interface SetupResult { skillId: string; /** True when every required step is satisfied or installed */ success: boolean; /** True when nothing was installed (checks only) */ checkOnly: boolean; steps: StepResult[]; /** Log file path (empty for check-only runs, which do not log) */ logFile: string; durationMs: number; /** Message of the first required step that failed or is missing */ error?: string; } /** Input to {@link SkillSetupRunner.runSetup}. */ export interface RunSetupInput { /** Skill id (used for the lock, log and default venv name) */ skillId: string; /** Absolute skill directory (install scripts are resolved inside it) */ skillDir: string; manifest: SkillSetupManifest; /** Only check; never install (no lock, no log) */ checkOnly?: boolean; onProgress?: (event: SetupProgressEvent) => void; } /** Size and hash of a downloaded file. */ export interface DownloadResult { bytes: number; sha256: string; } /** * Download `url` to `destPath`, hashing as it streams. * * @param url - Source URL * @param destPath - File to write (created/truncated) * @param onBytes - Called with the running byte count * @param stallTimeoutMs - Abort when no bytes arrive for this long * @returns Bytes written and their sha256 * @throws On HTTP errors, stalls and write errors */ export type DownloadFile = (url: string, destPath: string, onBytes: (received: number) => void, stallTimeoutMs: number) => Promise; /** Injectable dependencies. */ export interface SkillSetupRunnerDeps { run?: RunCommand; download?: DownloadFile; osFamily?: () => OsFamily; isRoot?: () => boolean; crewlyHome?: () => string; homeDir?: () => string; env?: NodeJS.ProcessEnv; now?: () => number; pid?: number; isPidAlive?: (pid: number) => boolean; /** Free bytes on the filesystem holding `dir`, or null when unknown */ freeBytes?: (dir: string) => number | null; sleep?: (ms: number) => Promise; lockPollMs?: number; lockWaitMs?: number; /** Directories searched for commands after PATH (default: Homebrew + system dirs) */ extraCommandDirs?: readonly string[]; /** Where to look for `brew` when it is not on PATH */ brewCandidates?: readonly string[]; } /** * Detect the OS family. * * @returns `darwin`, `debian` (Linux with /etc/debian_version) or `linux` */ export declare function detectOsFamily(): OsFamily; /** * Whether a pid belongs to a running process. * * @param pid - Process id * @returns True when signal 0 can be delivered (or is refused with EPERM) */ export declare function isPidAlive(pid: number): boolean; /** * Download with fetch, streaming to disk and hashing on the way. * * @param url - Source URL (redirects are followed) * @param destPath - Destination file * @param onBytes - Progress callback (running byte count) * @param stallTimeoutMs - Abort after this long without data * @returns Bytes and sha256 * @throws On HTTP errors, stalls and write errors */ export declare const defaultDownload: DownloadFile; /** Error raised when another process holds a skill's setup lock for too long. */ export declare class SetupLockTimeoutError extends Error { readonly skillId: string; readonly holderPid: number; /** * @param skillId - Skill whose lock is held * @param holderPid - Pid recorded in the lock file */ constructor(skillId: string, holderPid: number); } /** Runs setup manifests. */ export declare class SkillSetupRunner { private readonly run; private readonly download; private readonly osFamily; private readonly isRoot; private readonly crewlyHome; private readonly homeDir; private readonly env; private readonly now; private readonly pid; private readonly pidAlive; private readonly freeBytes; private readonly sleep; private readonly lockPollMs; private readonly lockWaitMs; private readonly extraCommandDirs; private readonly brewCandidates; /** Setup runs in flight in this process, by skill id */ private readonly inflight; /** * @param deps - Injectable dependencies (all optional) */ constructor(deps?: SkillSetupRunnerDeps); /** * Path of a skill's setup log. * * @param skillId - Skill id * @returns `$CREWLY_HOME/logs/skill-setup/.log` */ logFileFor(skillId: string): string; /** * Expand `~/` and `$CREWLY_HOME/` in a manifest path. * * @param p - Manifest path * @returns Absolute path */ expandPath(p: string): string; /** * Perform (or only check) a setup manifest. * * Concurrent calls for the same skill in this process share one run. * * @param input - Skill id, directory, manifest, mode and progress callback * @returns Per-step results; never rejects for step failures * * @example * ```ts * const result = await runner.runSetup({ skillId: 'transcribe-audio', skillDir, manifest }); * if (!result.success) console.error(result.error, 'see', result.logFile); * ``` */ runSetup(input: RunSetupInput): Promise; /** * Run every step (under the lock unless check-only). * * @param input - Run input * @returns The result */ private execute; /** * Dispatch one step. * * @param step - Step * @param input - Run input * @param emit - Progress emitter * @returns Step result */ private runStep; /** * Build a step result. * * @param step - Step * @param status - Final status * @param message - Message * @returns The result */ private stepResult; /** * PATH used for checks and installs: `$CREWLY_HOME/bin`, the process PATH, * then the usual system and Homebrew dirs (a backend started by launchd or * systemd often lacks /opt/homebrew/bin). * * @returns PATH string */ private searchPath; /** * Environment for child processes. * * @param extra - Additional variables * @returns Environment */ private childEnv; /** * Run a command, streaming its output into the log. * * @param logFile - Log file ('' = do not log) * @param skillId - Skill id * @param stepId - Step id * @param command - Executable * @param args - Arguments * @param timeoutMs - Timeout * @param env - Environment * @returns Exit code and combined output */ private exec; /** * What satisfies a command check, if anything. * * @param step - Command step * @returns A description of the match, or null */ private commandPresent; /** * How this process can run apt-get. * * @param packages - Packages (for the hint) * @returns The command + argument prefix, or an error message */ private aptAccess; /** * The value handed to install scripts in CREWLY_SUDO: '' (root), * 'sudo -n' (passwordless sudo) or 'unavailable'. * * @returns Sudo mode */ private sudoMode; /** * Check, install and re-check a command dependency. * * @param step - Command step * @param input - Run input * @param emit - Progress emitter * @returns Step result */ private runCommandStep; /** * Check, download, verify and place a file. * * @param step - File step * @param input - Run input * @param emit - Progress emitter * @returns Step result */ private runFileStep; /** * Check, create and fill a Python virtualenv. * * @param step - Python step * @param input - Run input * @param emit - Progress emitter * @returns Step result */ private runPythonStep; /** * Take the per-skill lock file, waiting while another live process holds it. * * @param skillId - Skill id * @param onWait - Called once when the lock is busy * @returns A release function * @throws SetupLockTimeoutError when the holder does not finish in lockWaitMs */ private acquireLock; /** * Append a timestamped line to a log file. * * @param logFile - Log file * @param skillId - Skill id * @param stepId - Step id * @param message - Message */ private log; /** * Append text to a log file (best effort). * * @param logFile - Log file * @param text - Text */ private appendRaw; } /** * Human-readable byte size. * * @param bytes - Size * @returns e.g. "547.4 MB" */ export declare function formatSize(bytes: number): string; /** * The process-wide runner (shares in-flight runs across callers). * * @returns The runner */ export declare function getSkillSetupRunner(): SkillSetupRunner; //# sourceMappingURL=skill-setup-runner.service.d.ts.map