Hooks
Hooks allow custom logic at different stages of the installation process.
Basic Usage
Section titled “Basic Usage”import { defineTool } from "@alexgorbatchev/dotfiles";
export default defineTool((install, ctx) => install("github-release", { repo: "owner/tool" }) .bin("tool") .hook("after-install", async (context) => { const { $, log, fileSystem } = context; await $`./tool init`; log.info("Tool initialized"); }),);Hook Events
Section titled “Hook Events”The four events, listed in the order one installation reaches them. An event is registered under exactly the name in the first column: the names are kebab-case, and registering any other name fails when the configuration is read, rather than leaving a handler that nothing would ever call.
| Order | Event | When | Adds to the context |
|---|---|---|---|
| 1 | before-install | Before the installer runs | stagingDir |
| 2 | after-download | After an asset is fetched to disk | downloadPath |
| 3 | after-extract | After an archive is unpacked | extractDir, extractResult |
| 4 | after-install | After the tool is in place | installedDir, binaryPaths, version |
An installation reaches only the events its method produces: a method that downloads
nothing never emits after-download, and one that extracts no archive never emits
after-extract.
extractResult describes what came out of the archive, as IExtractResult:
| Field | Type | Description |
|---|---|---|
extractedFiles | string[] | Every file that was unpacked, as an absolute path. |
executables | string[] | The unpacked files the extractor marked executable. |
It is provided only alongside extractDir, and never on its own: an empty pair of lists
handed to an event that extracted nothing would read as “the archive was empty”. Either
guard on extractDir, as the examples below do, or guard on extractResult itself.
A hook that throws fails the installation. Nothing is swallowed: if the handler rejects, the tool is reported as failed with the error the hook raised.
before-install is where a tool stages files itself: anything the hook puts into
stagingDir is promoted alongside what the installer produces. For a manual tool
without binaryPath the hook is the only thing that populates the staging directory,
so if it is still empty once the installer has run, the installation fails with an error
naming the directory instead of promoting an empty one, and after-install does not run.
Context Properties
Section titled “Context Properties”Every hook receives:
| Property | Description |
|---|---|
toolName | Name of the tool |
currentDir | Stable directory for this tool (the current symlink) |
stagingDir | Absolute path of the temporary directory the installer stages into |
toolDir | Directory holding this tool’s .tool.ts |
systemInfo | platform, arch, libc, homeDir and hostname of the target machine — see ctx.systemInfo |
projectConfig | Project configuration |
toolConfig | The resolved configuration of the tool being installed, as the installer sees it (ToolConfig) |
fileSystem | File operations — the fifteen methods, their signatures and IFileStats are in ctx.fs |
log | Structured logging (debug, info, warn, error) |
$ | Shell executor |
Plus whatever the event itself provides, per the table above. A property an event does
not provide is undefined rather than a misleading empty value, so destructuring
installedDir in a before-install hook gives you undefined — there is nothing
installed yet to point at.
Every event hands the handler the same type, IHookContext, exported from
@alexgorbatchev/dotfiles; the event-specific members are optional on it. Annotate a
handler’s parameter with it when the handler is declared separately from .hook().
$ is available only to hooks. A tool factory does not get one: configuration is read
on every CLI invocation, so running commands from there would execute them constantly.
toolConfig is likewise hook-only, for the same kind of reason: while defineTool is
still building the configuration there is nothing resolved to hand over. It carries the
tool as the installer sees it — name, version, installationMethod, installParams,
binaries, dependencies, symlinks, copies, shellConfigs, updateCheck,
hostname, sudo, disabled and configFilePath — which is how a hook reads back a
parameter it was configured with, such as the release it was installed from.
Working Directory
Section titled “Working Directory”Commands run from the directory containing the tool’s .tool.ts, so a script shipped
next to it is reached as ./scripts/setup.sh. Anywhere else you might want is already
in the context by name, and interpolating it says plainly which tree you mean:
.hook('after-install', async ({ $, installedDir }) => { await $`./scripts/setup.sh`; // next to the tool config await $`${installedDir}/bin/tool --version`; // the installed tree})Examples
Section titled “Examples”File Operations
Section titled “File Operations”.hook('after-install', async ({ fileSystem, projectConfig, log }) => { const configDir = `${projectConfig.paths.homeDir}/.config/tool`; await fileSystem.mkdir(configDir); // parents are created as needed await fileSystem.writeFile(`${configDir}/config.toml`, 'theme = "dark"'); log.info('Configuration created');})Shell Commands
Section titled “Shell Commands”.hook('after-install', async ({ $, installedDir }) => { // Run tool command await $`${installedDir}/tool init`;
// Capture output const version = await $`./tool --version`.text();})Executing Installed Binaries by Name
Section titled “Executing Installed Binaries by Name”In after-install hooks, the shell’s PATH is automatically enhanced to include the directories containing the installed binaries. This means you can execute freshly installed tools by name without specifying the full path:
.hook('after-install', async ({ $ }) => { // The installed binary is automatically available by name await $`my-tool --version`;
// No need to use full paths like: // await $`${installedDir}/bin/my-tool --version`;})This PATH enhancement only applies to after-install hooks where binaryPaths is available in the context.
Shell Command Logging
Section titled “Shell Command Logging”Shell commands executed in hooks are automatically logged to help with debugging and visibility:
- Commands are logged as
$ commandat info level before execution - Stdout lines are logged as
| lineat info level - Stderr lines are logged as
| lineat error level (only if stderr has content)
Example output:
$ my-tool init| Initializing configuration...| Configuration complete!.quiet() suppresses both the echoed command and its output, for commands whose output
is noise or is being captured with .text() instead.
Platform-Specific Setup
Section titled “Platform-Specific Setup”.hook('after-install', async ({ systemInfo, $ }) => { if (systemInfo.platform === Platform.MacOS) { await $`./setup-macos.sh`; } else if (systemInfo.platform === Platform.Linux) { await $`./setup-linux.sh`; }})File Text Replacement
Section titled “File Text Replacement”replaceInFile edits a file in place and returns whether anything changed. Every match
is replaced with or without the g flag, and the file is left alone when nothing matched
or when the result is identical to what was already there.
.hook('after-install', async ({ replaceInFile, installedDir }) => { await replaceInFile(`${installedDir}/config.toml`, /theme = ".*"/, 'theme = "dark"');})Full parameters, options and the callback argument are in utilities.md.
Build from Source
Section titled “Build from Source”.hook('after-extract', async ({ extractDir, stagingDir, $ }) => { if (extractDir) { await $`cd ${extractDir} && make build`; await $`mv ${extractDir}/target/release/tool ${stagingDir}/tool`; }})Inspecting What Was Extracted
Section titled “Inspecting What Was Extracted”extractResult saves walking the tree to find out what the archive held, and
toolConfig reads back the parameters the tool was configured with:
.hook('after-extract', async ({ extractResult, toolConfig, log }) => { if (!extractResult) return; log.info( `${toolConfig.name} unpacked ${extractResult.extractedFiles.length} files, ` + `${extractResult.executables.length} of them executable`, );})Error Handling
Section titled “Error Handling”.hook('after-install', async ({ $, log }) => { try { await $`./tool self-test`; } catch (error) { log.error('Self-test failed'); throw error; // Re-throw to fail installation }})Custom Binary Processing
Section titled “Custom Binary Processing”import { defineTool } from "@alexgorbatchev/dotfiles";
export default defineTool((install, ctx) => install("github-release", { repo: "owner/custom-tool" }) .bin("custom-tool") .hook("after-extract", async ({ extractDir, stagingDir, fileSystem, log }) => { if (extractDir) { // Custom binary selection and processing const binaries = await fileSystem.readdir(`${extractDir}/bin`); const mainBinary = binaries.find((name) => name.startsWith("main-"));
if (mainBinary) { await fileSystem.rename(`${extractDir}/bin/${mainBinary}`, `${stagingDir}/tool`); log.info(`Selected binary: ${mainBinary}`); } } }),);Environment-Specific Setup
Section titled “Environment-Specific Setup”import { Architecture, defineTool, Platform } from "@alexgorbatchev/dotfiles";
export default defineTool((install, ctx) => install("github-release", { repo: "owner/custom-tool" }) .bin("custom-tool") .hook("after-install", async ({ systemInfo, fileSystem, log, $ }) => { // Platform-specific setup if (systemInfo.platform === Platform.MacOS) { // macOS-specific setup await $`./setup-macos.sh`; } else if (systemInfo.platform === Platform.Linux) { // Linux-specific setup await $`./setup-linux.sh`; }
// Architecture-specific setup if (systemInfo.arch === Architecture.Arm64) { log.info("Configuring for ARM64 architecture"); await $`./configure-arm64.sh`; } }),);Environment Variables in Installation
Section titled “Environment Variables in Installation”Set environment variables during installation (for curl-script installs):
import { defineTool } from "@alexgorbatchev/dotfiles";
export default defineTool((install) => install("curl-script", { url: "https://example.com/install.sh", shell: "bash", env: { INSTALL_DIR: "~/.local/bin", ENABLE_FEATURE: "true", API_KEY: process.env.TOOL_API_KEY || "default", }, }).bin("my-tool"),);Best Practices
Section titled “Best Practices”- Use
$for shell operations that need to work with files relative to your tool config - Use
fileSystemmethods for cross-platform file operations that don’t require shell features - Always handle errors appropriately in hooks to provide clear feedback
- Use
logfor all output - avoidconsole.log()in favor of structured logging:log.info()for general informationlog.warn()for warningslog.error()for error conditionslog.debug()for debugging and troubleshooting
- Test your hooks on different platforms to ensure compatibility
- Keep hooks focused - each hook should have a single responsibility
- Document complex logic - explain what your hooks are doing and why
Complete Example
Section titled “Complete Example”import { defineTool } from "@alexgorbatchev/dotfiles";
export default defineTool((install, ctx) => install("github-release", { repo: "owner/custom-tool" }) .bin("custom-tool") .symlink("./config.yml", "~/.config/custom-tool/config.yml") .hook("before-install", async ({ log }) => { log.info("Starting custom-tool installation..."); }) .hook("after-extract", async ({ extractDir, log, $ }) => { if (extractDir) { // Build additional components log.info("Building plugins..."); await $`cd ${extractDir} && make plugins`; } }) .hook("after-install", async ({ toolName, installedDir, projectConfig, fileSystem, log, $ }) => { // Create data directory const dataDir = `${projectConfig.paths.homeDir}/.local/share/${toolName}`; await fileSystem.mkdir(dataDir);
// Initialize tool await $`${installedDir}/${toolName} init --data-dir ${dataDir}`;
// Set up completion await $`${installedDir}/${toolName} completion zsh > ${projectConfig.paths.generatedDir}/completions/_${toolName}`;
log.info(`Initialized ${toolName} with data directory: ${dataDir}`); }) .zsh((shell) => shell.env({ CUSTOM_TOOL_DATA: "~/.local/share/custom-tool" }).aliases({ ct: "custom-tool" })),);