Skip to content

feat(modules): run scripts when a module is deployed or undeployed - #318

Merged
mindsers merged 5 commits into
developfrom
feature/post-deploy-hooks
Oct 13, 2026
Merged

mindsers merged 5 commits into
developfrom
feature/post-deploy-hooks

Conversation

@mindsers

@mindsers mindsers commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes #298.

configfile deploys the configuration files (dotfiles) of a git repository: each folder of files/ is a module whose settings.json lists files to link into the home folder, and scripts/ holds scripts run with configfile scripts run <name>.

Many modules need a step around their files: tmux needs its plugins installed, mise has to install the tools its configuration lists, a service has to be stopped before its configuration is removed. Until now these steps lived in one setup script run by hand, and configfile modules deploy tmux alone left the module half set up.

With this change, a module names the scripts to run when it is deployed or undeployed:

{
  "files": [
    { "source_path": "tmux.conf", "target_path": "~/.tmux.conf", "deploy": "global" }
  ],
  "post_deploy": "tmux-plugins",
  "pre_undeploy": "tmux-plugins-remove"
}
Setting The script runs
pre_deploy before modules deploy deploys the files of the module
post_deploy once modules deploy has deployed all of them
pre_undeploy before modules undeploy removes the files, while they are still in place
post_undeploy once modules undeploy has removed them

The issue asked for post_deploy only. The three other settings follow the same rules and share the same code, so that a module can also check what it needs before its files are linked, and clean up what its script installed.

Behavior

  • The value is a script name, as configfile scripts list shows it. The script is run the way scripts run runs it (versions per system such as tmux-plugins.macos.sh, interpreter detection, current folder), without arguments. It also gets CONFIGFILE_HOOK (the setting), CONFIGFILE_MODULE and CONFIGFILE_DEPLOY (global or local).
  • One module at a time, in the order of modules list: the pre_ script, the files, the post_ script, then the next module.
  • Every time: the scripts run on each deploy or undeploy of a module that has files of the kind the command works on, including when the files were already up to date. Scripts must be safe to run again.
  • A pre_ script that fails leaves the files of its module as they are, and its post_ script is not run.
  • A post_deploy script is not run when a file of its module failed or was skipped, or an entry is invalid (with a warning). post_undeploy is not run when a file could not be undeployed.
  • A script that fails (non-zero exit, or cannot be started) stops the command: the modules after it are left as they are, and the command exits with 1. --keep-going goes on with the remaining modules; the command still fails.
  • A name that matches no script is found before anything is changed: the command names every such script and fails. With --keep-going it starts anyway, and the script fails when its turn comes.
  • A script that only has versions for other systems (tmux-plugins.macos.sh alone, on Linux) is skipped, with a message: the module is deployed or undeployed as if it named no script.
  • A module without files of the kind the command works on (for example a module with only global files, with --local) does not run its scripts; when that module was named on the command line, the command says so with a warning.
  • A setting that is not a script name (not text, or empty) is ignored. Its own command reports it as an error and fails once the files are done; the other command only warns; modules list and status show it as an error.
  • Ctrl+C or SIGTERM while a script runs is left to the script, as with scripts run. Once it ends, the command stops there, with or without --keep-going, and exits with the signal's code (130 for Ctrl+C, 143 for SIGTERM).
  • --no-hooks runs no script. --dry-run lists the scripts that would run, and fails like the real command on one that does not exist.
  • modules status shows the scripts of each module and flags a name that matches no script; when scripts/ cannot be listed, it warns and still shows the files. modules list names the settings a module uses.
  • The history (~/.configfile/history.jsonl) records each script with its exit code, setting and module, and the scripts that were not run on purpose.
  • No script runs for the files the repository no longer deploys (undeploy --removed): their module or entry is gone, with its settings.

Design

  • The lock is released while a script runs. modules deploy and undeploy hold ~/.configfile/lock while they change files. A script may run configfile itself, which would wait for that lock until it times out. The lock is therefore taken for the files of each module in turn, instead of the whole run. If it cannot be taken again for a later module, the command reports it and names the modules left out.
  • One runner for both commands. eachModule in src/commands/modules.ts holds the order, the stop and --keep-going logic and the summary; deploy and undeploy only give it the work to do on the files of a module.
  • Scripts are found like scripts run finds them. loadScripts and scriptEnvironment moved from src/commands/scripts.ts to src/repository.ts to be shared; scripts run is unchanged.
  • A lock that cannot be used (src/lock.ts) is now an expected error with a message, where a folder that could not be created or written used to be reported as an unexpected error.
  • Signals. run in src/process.ts gets an onSignal callback, so that the caller knows configfile was asked to stop while the script ran, whatever the script's exit code.

Other Informations

Compatibility

Nothing changes for a repository that uses none of the new settings, with four exceptions. The first two are under "Changed" in the changelog, the third under "Fixed":

  • When a local file already exists (modules deploy --local), the question to replace it is asked once the other files of its module are deployed, no longer at the very end of the run.

  • Two deployments running at the same time can take turns between two modules, where one used to wait for the other to finish. Files are still never changed by two processes at once.

  • When ~/.configfile or its lock file cannot be created or written, the commands say that the lock cannot be taken, and why, instead of reporting an unexpected error.

  • When the lock or the deployment record can no longer be used partway through a run (after a first module, or for the files the repository no longer deploys at the end of undeploy --all), the command reports the error, then says what was done and which modules were left out, instead of stopping on the error alone.

The history format stays at version 1: script records gain two optional fields (hook, module), and a new kind of record, script-skipped, is added for the scripts not run on purpose (readers skip the kinds they do not know).

Review guide

  1. src/repository.ts: the settings are read (HOOKS, hooks, hookProblems).
  2. src/commands/modules.ts: loadHookScripts (the names are checked first), eachModule, runHook and planHook, then deploy and undeploy, which use them.
  3. src/process.ts, src/history.ts, src/commands/history.ts: signals and history.
  4. docs/: the reference (reference/modules.md), a new guide (guides/module-scripts.md), and the JSON Schema of settings.json.

Tests

pnpm check passes. New tests, mostly in tests/commands.test.ts ("scripts run when a module is deployed or undeployed"), run real scripts through the CLI and check files, exit codes and the history:

  • order of the scripts and the files, environment variables, versions per system, lock released;
  • a failing or unstartable script, with and without --keep-going, for the four settings; a script that does not exist, found before anything is changed;
  • scripts withheld when files fail, are skipped or an entry is invalid; interactive answers;
  • --no-hooks, --dry-run, modules status and list, the history;
  • a script that only has a version for another system; a module named without files of the kind; an unusable setting of the other command (warning only);
  • the record becoming unusable between two modules, and for the removed files at the end of undeploy --all; a lock that cannot be used before anything was done; a question cancelled with Ctrl+C;
  • modules status when scripts/ cannot be listed;
  • the files the repository no longer deploys, when a script stops undeploy --all and when it does not;
  • end to end (tests/e2e.test.ts): SIGINT during a pre_deploy or post_deploy script stops a --keep-going deployment with exit code 130, and SIGTERM during a pre_undeploy script stops the undeployment with 143.

Manual test

From the root of a checkout of this branch (Node.js 24.11 or later). It uses a throwaway home folder:

cli="$PWD/src/cli.ts"
export HOME="$(mktemp -d)"            # throwaway home: your own files are not touched
repo="$HOME/dotfiles"
mkdir -p "$repo/files/tmux" "$repo/files/zsh" "$repo/scripts"
echo 'set -g mouse on' > "$repo/files/tmux/tmux.conf"
echo 'export EDITOR=vim' > "$repo/files/zsh/zshrc"
cat > "$repo/files/tmux/settings.json" <<'JSON'
{
  "files": [{ "source_path": "tmux.conf", "target_path": "~/.tmux.conf", "deploy": "global" }],
  "post_deploy": "plugins",
  "pre_undeploy": "plugins-remove"
}
JSON
cat > "$repo/files/zsh/settings.json" <<'JSON'
{ "files": [{ "source_path": "zshrc", "target_path": "~/.zshrc", "deploy": "global" }] }
JSON
printf 'echo "[$CONFIGFILE_HOOK of $CONFIGFILE_MODULE] installing plugins"\nexit "${FAIL:-0}"\n' > "$repo/scripts/plugins.sh"
printf 'echo "[$CONFIGFILE_HOOK of $CONFIGFILE_MODULE] removing plugins"\n' > "$repo/scripts/plugins-remove.sh"
printf '{"repo_url":null,"folder_path":"%s"}' "$repo" > "$HOME/.configfilerc" && chmod 600 "$HOME/.configfilerc"
cd "$HOME"

node "$cli" modules status                       # 1. lists the scripts of tmux
node "$cli" modules deploy --all --dry-run       # 2. "would run", nothing runs
node "$cli" modules deploy --all                 # 3. tmux files, its script, then zsh
node "$cli" modules undeploy zsh
FAIL=3 node "$cli" modules deploy --all          # 4. script fails: zsh is not deployed, exit 1
FAIL=3 node "$cli" modules deploy --all --keep-going   # 5. zsh is deployed anyway, exit 1
node "$cli" modules deploy --all --no-hooks      # 6. no script runs, exit 0
node "$cli" modules undeploy --all               # 7. pre_undeploy runs before the link is removed
node "$cli" history -n 3                         # 8. scripts are recorded with their module

Step 4 ends with:

 Error  tmux: post_deploy script "plugins" exited with code 3.
 Error  The post_deploy script of tmux failed. 1 module was not deployed: zsh. Use --keep-going to go on after a script that fails.

Follow-up: scripts run after update and init, which belong to the repository and not to a module, are proposed in #317.

A module's settings.json can name scripts of the repository with
pre_deploy, post_deploy, pre_undeploy and post_undeploy. modules deploy
and modules undeploy now work on one module at a time: the pre_ script,
the files (under the lock), then the post_ script (without it).

A script that fails stops the command there unless --keep-going is
given, and a pre_ script that fails leaves the files of its module as
they are. --no-hooks runs none, --dry-run lists them, modules status
shows them, and the history records them with their module.

Closes #298
Also mentions them in the tutorial, the new machine and CI guides, and
the concepts page.
Keeps both changelog entries, and links the new guide from the README.
…un ends early

- A name that matches no script fails the command before anything is
  changed (it used to fail when its turn came, after earlier modules
  were deployed). With --keep-going it still fails in its turn. Dry
  runs now stop where the real run stops.
- undeploy keeps its summary when the removed files cannot be checked.
- modules status still shows the files when scripts/ cannot be listed.
- A setting of the other command that cannot be used is a warning.
- Says when a script is not run because its module has no file of the
  kind the command works on.
- A lock that cannot be taken before anything was done is reported as
  before, and a lock folder that cannot be used is no longer a crash.
- Records in the history the post_ scripts that were not run.
… scripts

- A script that only has versions for other systems is skipped, with a
  message, instead of failing the command as a name that matches nothing.
- The warning about a script not run because its module has no file of
  the kind is only shown for modules named on the command line.
- Scripts not run on purpose are recorded in the history as
  script-skipped, not as failures.
- The history keeps the modules left out on every way out of a run, and
  the reason the removed files could not be checked.
- A dry run with --keep-going words a missing script like the real run.
- Tests for the removed-files step failing, a cancelled question and the
  lock errors; signal tests no longer depend on a 0.3 s window.
@mindsers
mindsers merged commit 35d3e21 into develop Oct 13, 2026
7 checks passed
@mindsers
mindsers deleted the feature/post-deploy-hooks branch October 13, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Post-deploy hooks per module

1 participant