Skip to content

Repository files navigation

Checker

Lint and test

Get notified when stuff changes.

Install

Every release ships a binary per target; grab the one matching your machine — checker-aarch64-apple-darwin for Apple Silicon, checker-x86_64-unknown-linux-gnu or checker-aarch64-unknown-linux-gnu for Linux.

mkdir -p ~/.local/bin
curl -fsSL -o ~/.local/bin/checker https://github.com/cameronmurphy/checker/releases/latest/download/checker-aarch64-apple-darwin
chmod +x ~/.local/bin/checker

Updating

checker self-update

The newest release for this platform replaces the installed binary. The download is checked against the SHA256SUMS published with the release and discarded if it doesn't match, the previous binary is kept alongside as checker.previous, and nothing happens at all when the installed version is already the newest.

Where a checker is running as a service, it does the work itself rather than the CLI doing it behind its back: the CLI reaches it over a socket beside the config file, so the two can't disagree about which binary is installed, and it exits once the swap is done so KeepAlive restarts it on the new one. The swap is a rename, so the running process keeps the binary it started from until that moment.

With nothing running there's nothing to co-ordinate with, so the CLI swaps the binary itself and says so — the new one is picked up whenever checker next starts. Running from a source checkout it refuses outright, since the binary it would replace is deno itself.

Configuration

Checker reads ~/.config/checker/config.yml. Starting it without one writes the example config there and stops, so the first run is the setup step:

checker # Writes ~/.config/checker/config.yml
vim ~/.config/checker/config.yml # Set up what you want to watch
checker # Starts monitoring

That config runs as it stands, with no credentials to fill in first: it follows the International Space Station and appends a line to /tmp/checker-notifications.log every time it moves, so a fresh install does something you can see and nothing you have to clean up. Every other built-in source and destination is in there too, commented out and already indented where it belongs, so swapping the ISS for what you actually care about is mostly uncommenting. Nothing needs to be cloned or copied by hand — the binary carries the example. Pointing --config-file somewhere else works the same way: checker writes it at whatever path it was told to read.

Contexts

A context pairs the things you watch with where their updates go. Configs don't have to mention them: everything under sources and destinations belongs to a context named default, which is all most setups need.

Reach for contexts when one group of sources should reach somewhere different from another — say a project's own dependencies driving a Claude Code routine for that project, while general-interest sources only reach your phone:

config:
contexts:
default:
sources:
sheeran:
items: ['Australia']destinations:
pushover: &pushover # An anchor, so other contexts can reuse these credentialstoken: 'your-pushover-token'user_key: 'your-user-key'myapp:
sources:
docker:
items: ['nginx'] # The same plugin can watch different things in each contextdestinations:
pushover: *pushoverclaude_code:
routine_id: 'trig_01ABCDEFGHJKLMNOPQRSTUVW'token: 'sk-ant-oat01-...'

Use one shape or the other — a config with contexts alongside top-level sources/destinations is rejected. default is only the name used when contexts is absent, so writing it out explicitly changes nothing, including the state already on disk.

Each context keeps its own state, so two contexts watching the same thing notify independently. That also means moving a source between contexts re-reads its items as first seen, and it will notify once more from its new context.

Within a context, a source notifies every destination unless it names a subset with destinations.

Naming sources and destinations

Each key under sources and destinations names one of them, and by default that name is the plugin's. Add plugin: to name it something else, which is what lets one plugin back several:

config:
sources:
filepond: # A new stable release opens an upgrade PRplugin: npmitems: ['filepond']destinations: [claude_code]filepond_beta: # A beta is just worth knowing aboutplugin: npmitems: ['filepond@beta']destinations: [pushover]destinations:
pushover:
token: 'your-pushover-token'user_key: 'your-user-key'claude_code:
routine_id: 'trig_01ABCDEFGHJKLMNOPQRSTUVW'token: 'sk-ant-oat01-...'

The name is what everything else refers to — a source's destinations list, and the errors block below — and referring to one that isn't configured is rejected rather than quietly notifying nobody. A source's state is stored under its name too, so renaming a source re-reads its items as first seen, the same as moving it between contexts.

A YAML anchor is the way to share one set of credentials across contexts, and an alias of it doesn't need plugin: even where the name it lands under isn't the plugin's. The definition the anchor sits on is keyed by the plugin name, and that travels with it:

config:
contexts:
wrx:
destinations:
claude_code: &claude_code_wrx # Keyed by the plugin, so the alias below knows what it isroutine_id: 'trig_01ABCDEFGHJKLMNOPQRSTUVW'token: 'sk-ant-oat01-...'node:
destinations:
claude_code_wrx: *claude_code_wrx # The same routine, under the name this context uses

That only applies to an alias of a definition keyed by a plugin name. One written out under a name of your own still needs plugin:, since there's nothing for it to have come from.

Errors

Failures anywhere in the daemon — a source that threw, a plugin that failed to load, a config that wouldn't parse — go to the daemon's log. Adding an errors key to a context sends them somewhere you'll actually see:

config:
contexts:
default:
destinations:
updates:
plugin: log_filepath: '~/Library/Logs/checker-updates.log'failures: # A second log file, so failures aren't buried in the updatesplugin: log_filepath: '~/Library/Logs/checker-errors.log'errors:
destinations: [failures] # Optional, by default notify every destination in the contextmyapp:
errors:
destinations: [pushover]

The default context's errors is the fallback: it covers every context that doesn't declare its own, plus the failures nothing can be attributed to. Above, myapp's failures reach your phone and everything else lands in the log file. Each failure is reported once, so a source that's been down for a week doesn't notify on every check.

Running a script

Most destinations announce an update. The script destination acts on one, by running a local command:

config:
sources:
virtualbox:
items: ['latest']destinations: [pushover, vbox_upgrade]destinations:
pushover:
token: 'your-pushover-token'user_key: 'your-user-key'vbox_upgrade:
plugin: scriptcommand: '~/.local/bin/vbox-upgrade.sh'timeout: 2700

The message arrives on stdin and in $CHECKER_MESSAGE, the destination's own name is in $CHECKER_DESTINATION, and {{message}} in any argument is substituted with it. The script's output is inherited rather than captured, so a long upgrade reports progress into checker's log while it runs.

A zero exit status counts as delivered. Exit non-zero and the source keeps its old value, so the next check hands the script the same update again rather than losing an upgrade to a download that happened to fail. That retry is per destination only in the sense that the value is kept — a source whose other destinations succeeded has already been delivered, so pair a script with pushover and a failed upgrade won't be retried. Give the script its own source entry if you want both the notification and the retry.

Nothing about the script is run through a shell: command is executed directly, so there's no quoting to get wrong, and no interpolation of the message anywhere it could be treated as syntax. A script still outliving its timeout is sent SIGTERM, defaulting to 900 seconds.

Writing a plugin

Sources

Source plugins by default go in ~/.config/checker/plugins/source. Here's an example, sheeran.ts, which is a plugin that checks whether Ed Sheeran is playing in certain countries any time soon.

importBaseSourcePlugin,{SourceConfigSchema}from'checker/plugins/source';importCaseInsensitiveComparatorfrom'checker/comparator/case-insensitive';import{DOMParser}from'checker/parse';import{z}from'zod';constSheeranConfigSchema=SourceConfigSchema.extend({items: z.array(z.string()).min(1,'Sheeran plugin requires at least one country name'),});typeSheeranConfig=z.infer<typeofSheeranConfigSchema>;exportdefaultclassSheeranSourceextendsBaseSourcePlugin<SheeranConfig>{privatereadonlycomparator=newCaseInsensitiveComparator();publicoverridegetSchema(){returnSheeranConfigSchema;}publicoverrideasyncread(item: string){constresponse=awaitfetch('https://www.edsheeran.com/');consttext=awaitresponse.text();constdoc=newDOMParser().parseFromString(text,'text/html');constlocations=Array.from(doc?.querySelectorAll('.event_location')||[]);constrelevantLocations=locations.filter((el)=>el.textContent?.includes(item));constdates=relevantLocations.map((el)=>el.parentElement?.querySelector('.event_date')?.textContent?.replace(/\s+/g,' ').trim());returndates.filter(Boolean).join(', ');}publicoverrideupdated(before: string,after: string){returnthis.comparator.updated(before,after);}publicoverridemessage(_before: string,after: string,item: string){return`Ed Sheeran is playing in ${item} on ${after}!`;}}

The class name determines the default config key: SheeranSource has its Source suffix stripped and the rest snake-cased, giving sheeran.

Then you would configure this plugin like so:

config:
sources:
sheeran:
items:
- 'Australia'

Imports available to plugins

Plugins are loaded at runtime, which means they can only import what checker was built with — a distributed binary has no module cache to fetch from and no network access at load time. Import from these specifiers rather than jsr: URLs, and everything resolves offline:

SpecifierWhat you get
checker/plugins/sourceBaseSourcePlugin (default), SourceConfigSchema
checker/plugins/destinationBaseDestinationPlugin (default), DestinationConfigSchema
checker/comparatorBaseComparator, to write your own
checker/comparator/case-insensitiveCaseInsensitiveComparator — any change in text, ignoring case
checker/comparator/debianDebianComparator — a higher Debian package version, epochs, revisions and ~ included
checker/comparator/intIntComparator — a bigger number than last time
checker/comparator/semverSemverComparator — a higher version, falling back to inequality for tags that aren't semver
checker/comparator/strlenStrlenComparator — the text got longer
checker/parseDOMParser, parseXml, parseYaml, parseToml, parseCsv, parseJsonc, unescapeHtml
zodz, for the config schema

Importing anything else fails with Module not found; checker logs that and carries on without the plugin, so one bad plugin can't stop the rest from running.

Adding a plugin file only needs a config save to pick it up, but editing one needs a restart — the runtime caches modules it has already loaded.

Dev setup (macOS)

Install Homebrew.

brew bundle

Ensure mise activate is in your shell rc/profile. If it needed to be added, restart your terminal session.

mise install

Scripts

Dev

Run the app and automatically reload when the code changes.

deno task dev

Run

Run the app.

deno task run

Run the app against a different config file.

deno task run --config-file /usr/local/etc/checker/config.yml

Upgrade deps

To check for outdated dependencies:

deno outdated

To update:

deno outdated --update

Running as a service (macOS)

Checker polls on its own schedule, so it wants to stay resident rather than be re-launched on a timer. The service runs the installed binary against a config you've already set up — a service that starts with no config writes the example one and exits, and KeepAlive will keep restarting it into the placeholders.

Install the launch agent, substituting your home directory for the placeholder. Run it from the repository root so the template path resolves.

mkdir -p ~/Library/LaunchAgents
sed -e "s|__HOME__|$HOME|g" contrib/launchd/com.camurphy.checker.plist \
>~/Library/LaunchAgents/com.camurphy.checker.plist
launchctl load ~/Library/LaunchAgents/com.camurphy.checker.plist

The launch agent redirects checker's output to ~/Library/Logs/checker.log; change StandardOutPath and StandardErrorPath in the plist to put it elsewhere. That file is the record of what the daemon did, not a place to watch for trouble — configure errors to have failures notify a destination.

Config changes don't need a restart. Checker watches the config file and re-reads it on save, so adding a source or an item takes effect within a second; sources whose config didn't change keep their existing schedule and aren't re-checked. A config that fails to parse is logged and ignored, leaving the running config in place.

Dropping a new plugin into the plugin directory also takes effect on the next config save. Editing an existing plugin needs the service restarted, as does installing a new binary.

Updating the binary is checker self-update, which the running service handles itself.

About

Get notified when stuff changes

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages