headplane-acl-preview-overlay¶
Overlays
Carry a working local UI feature on top of a packaged web app — replacing an
upstream stub / "coming soon" placeholder component — using a Nix
overrideAttrs postPatch, without forking upstream.
The worked example targets Headplane
(a Headscale admin UI). Its ACL "preview" tab ships an upstream
<Construction /> placeholder; this overlay swaps in a real client-side access
matrix (Groups, Access Rules, SSH Rules) rendered from the policy JSON the page
already has in hand.
The problem¶
You want a feature the upstream package doesn't provide yet (or renders as a
stub), but you don't want to maintain a fork: forks drift, need rebasing on
every release, and turn a one-line version bump into a merge chore. If the
package builds from source inside its derivation (bundling happens after
postPatch), you can graft the change in at package-build time and keep
tracking upstream normally.
The insight¶
A UI stub swap is really four independent source edits, each with a matching Nix/shell primitive:
| Step | Goal | Tool |
|---|---|---|
| 1 | get your component into the source tree | cp ${./component.tsx} <dest> |
| 2 | wire it into the page | sed -i '1i import ...' (imports must precede JSX) |
| 3 | put your JSX where the stub was | substituteInPlace --replace-fail |
| 4 | remove the leftover placeholder prose | sed -i '/<p .../,/<\/p>/d' |
The replacement component (acl-preview-component.tsx) is referenced by Nix
path, so it becomes a store input — edit it and the package rebuilds.
Traps / gotchas¶
- Use
--replace-fail, not--replace. If a future upstream release renames or removes the<Construction />placeholder,--replace-failmakes the build fail loudly instead of silently shipping the untouched stub. This is the single most important line: it turns "my patch stopped working" from a runtime mystery into a build error. Re-verify the anchor string on every version bump. - Append to
postPatch, don't overwrite it.(old.postPatch or "") + ''…''preserves any patching the upstream package already does. - Append to
nativeBuildInputstoo. The in-place edits needgnused; add it defensively even if the builder already has it. - The import must go before line 1's JSX.
sed '1i'inserts a new first line, keeping imports at the top of the module. - Delete the placeholder prose, or it renders alongside your component. Replacing the stub element (step 3) does not remove the "coming soon" paragraph next to it; the range delete (step 4) does. Keep the delete last so its anchor text isn't disturbed by an earlier edit.
- Only works for source-built packages. If the package installs a pre-bundled/minified artifact, there's no readable source to patch — grafting must happen upstream of the bundler.
Usage¶
That's the whole wiring — the overlay overrides pkgs.headplane in place, so
anything that references it (a NixOS service module, a pkgs.headplane in an
env) picks up the patched build.
Adapting it to another app¶
- Point the
cpdestination at wherever your app expects the component. - Change the import path in step 2 to match.
- Change the
--replace-failanchor to your placeholder's exact JSX. - Adjust (or drop) the step-4 range delete to match your placeholder markup.
- Rewrite
acl-preview-component.tsxto render your feature.
Files¶
default.nix— the overlay (the four-steppostPatch).acl-preview-component.tsx— the replacement React component (a template).
Source¶
overlays/headplane-acl-preview-overlay/default.nix
# headplane-acl-preview-overlay
#
# Swap an upstream web-UI package's stub / "coming soon" component for a
# working local one via a `overrideAttrs` `postPatch`, WITHOUT forking the
# upstream repo. Here the target is Headplane's ACL "preview" tab, whose
# upstream renders a `<Construction />` placeholder; we drop in a real
# client-side access-matrix component instead.
#
# The technique is generic. Any packaged JS/TS app that ships a placeholder
# React (or Vue/Svelte) component can have it replaced this way as long as the
# app is built FROM SOURCE inside the derivation (so a `postPatch` runs before
# the bundler). The four moves below are the reusable pattern:
#
# 1. cp — drop your replacement component into the source tree
# 2. sed insert import — wire the new component into the page that renders it
# 3. substituteInPlace — swap the placeholder JSX for your component's JSX
# 4. sed delete — remove the leftover "coming soon" prose so it does
# not render alongside your component
#
# Import it as a nixpkgs overlay:
#
# nixpkgs.overlays = [ (import ./headplane-acl-preview-overlay) ];
#
# The replacement component lives beside this file as
# `acl-preview-component.tsx`; edit it to taste. The upstream source paths
# (app/routes/acls/...) are Headplane-specific — retarget them for your app.
final: prev: {
headplane = prev.headplane.overrideAttrs (old: {
# gnused is needed for the in-place line insert/delete. Some upstream
# builders already have it; appending is safe either way.
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.gnused ];
# Append to any existing postPatch rather than clobbering it — the
# upstream package may already patch its own sources.
postPatch = (old.postPatch or "") + ''
# 1. Copy the replacement component into the app source tree.
cp ${./acl-preview-component.tsx} app/routes/acls/acl-preview.tsx
# 2. Inject the import at the top of the page that renders the stub.
# `1i` inserts before line 1 (imports must precede any JSX).
sed -i '1i import AclPreview from "./acl-preview";' app/routes/acls/overview.tsx
# 3. Replace the placeholder element with our component.
# --replace-fail makes the build FAIL LOUDLY if upstream renames or
# removes `<Construction />` — far better than a silent no-op that
# ships the stub. Re-check this string whenever you bump the package.
substituteInPlace app/routes/acls/overview.tsx \
--replace-fail '<Construction />' '<AclPreview policy={codePolicy} />'
# 4. Delete upstream's leftover "coming soon" paragraph so it does not
# render above/below our component. This is a range delete from the
# opening `<p className="mt-4 ...>` through its closing `</p>`.
# ORDERING: this runs AFTER the substitute above; the two edits touch
# different lines, but keep the delete last so the anchor text you
# match here is not accidentally altered by an earlier edit.
sed -i '/<p className="mt-4/,/<\/p>/d' app/routes/acls/overview.tsx
'';
});
}