summaryrefslogtreecommitdiffstatshomepage
path: root/scripts
diff options
context:
space:
mode:
authorsommerfeld <sommerfeld@sommerfeld.dev>2026-09-17 15:05:36 +0100
committersommerfeld <sommerfeld@sommerfeld.dev>2026-09-17 15:05:36 +0100
commitaff435a0fb3a56b1d7b738c7dede363440884b3a (patch)
tree07acb41d37760aa169ee42dbeb69da3621cc7489 /scripts
parenta7f74c1fa4b89c5829f5896842eb7248c820704c (diff)
downloaddotfiles-aff435a0fb3a56b1d7b738c7dede363440884b3a.tar.gz
dotfiles-aff435a0fb3a56b1d7b738c7dede363440884b3a.tar.bz2
dotfiles-aff435a0fb3a56b1d7b738c7dede363440884b3a.zip
Add the Canonical laptop profile and setup workflows
Diffstat (limited to 'scripts')
-rw-r--r--scripts/__init__.py0
-rw-r--r--scripts/canonical-system.sh18
-rw-r--r--scripts/canonical.py137
-rw-r--r--scripts/canonical_profiles.py74
-rw-r--r--scripts/maintenance-lib.sh38
-rw-r--r--scripts/nix-daemon-update.sh12
6 files changed, 279 insertions, 0 deletions
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/scripts/__init__.py
diff --git a/scripts/canonical-system.sh b/scripts/canonical-system.sh
new file mode 100644
index 0000000..e6f0825
--- /dev/null
+++ b/scripts/canonical-system.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")/.."
+source just-lib.sh
+[[ $(_machine_role) == canonical ]]
+[[ $(
+ # shellcheck disable=SC1091
+ . /etc/os-release
+ echo "$ID"
+) == ubuntu ]]
+# Parse before replacing the installed profile.
+sudo apparmor_parser --skip-kernel-load --skip-cache canonical/apparmor/dotfiles-nix
+sudo install -m 644 canonical/apparmor/dotfiles-nix /etc/apparmor.d/dotfiles-nix
+sudo apparmor_parser --replace /etc/apparmor.d/dotfiles-nix
+sudo snap connect thunderbird:gpg-keys
+systemctl --user daemon-reload
+systemctl --user enable --now gpg-agent.socket gpg-agent-ssh.socket podman.socket
+echo 'Existing GPG agent processes keep their executable until the next login.'
diff --git a/scripts/canonical.py b/scripts/canonical.py
new file mode 100644
index 0000000..2ff6310
--- /dev/null
+++ b/scripts/canonical.py
@@ -0,0 +1,137 @@
+"""Install declared corporate packages without removing existing packages."""
+
+import argparse
+import json
+import os
+import platform
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def packages(source: str) -> list[str]:
+ return [
+ line.strip()
+ for line in (ROOT / "meta/canonical" / f"{source}.txt").read_text().splitlines()
+ if line.strip() and not line.startswith("#")
+ ]
+
+
+def snap_install_commands() -> list[list[str]]:
+ return [
+ ["sudo", "snap", "install", name, "--channel=stable"]
+ + (["--classic"] if name == "ghostty" else [])
+ for name in packages("snap")
+ ]
+
+
+def flatpak_install_commands() -> list[list[str]]:
+ return [
+ [
+ "flatpak",
+ "remote-add",
+ "--user",
+ "--if-not-exists",
+ "flathub",
+ "https://flathub.org/repo/flathub.flatpakrepo",
+ ],
+ [
+ "flatpak",
+ "install",
+ "--user",
+ "--assumeyes",
+ "flathub",
+ *packages("flatpak"),
+ ],
+ ]
+
+
+def update_commands() -> list[list[str]]:
+ # An untargeted refresh respects Snap holds. Explicit targets override them.
+ return [
+ ["sudo", "apt-get", "update"],
+ ["sudo", "apt-get", "upgrade"],
+ ["sudo", "snap", "refresh"],
+ ]
+
+
+def require_canonical() -> None:
+ data = json.loads(
+ subprocess.check_output(["chezmoi", "data", "-S", str(ROOT)], text=True)
+ )
+ if data.get("machineRole") != "canonical":
+ raise SystemExit("This command requires machineRole=canonical.")
+ if platform.freedesktop_os_release().get("ID") != "ubuntu":
+ raise SystemExit("This command requires Ubuntu.")
+
+
+def install() -> None:
+ subprocess.run(["sudo", "apt-get", "update"], check=True)
+ subprocess.run(["sudo", "apt-get", "install", *packages("apt")], check=True)
+ for command in snap_install_commands():
+ if subprocess.run(
+ ["snap", "list", command[3]],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ ).returncode:
+ subprocess.run(command, check=True)
+ for command in flatpak_install_commands():
+ subprocess.run(command, check=True)
+
+
+def check() -> None:
+ commands = [
+ ["lsb_release", "-ds"],
+ *[
+ ["systemctl", "is-active", unit]
+ for unit in ["display-manager", "snapd", "apparmor", "nix-daemon"]
+ ],
+ ["landscape-config", "--actively-registered"],
+ ["snap", "connections", "thunderbird"],
+ ["snap", "list", *packages("snap")],
+ ["gnome-extensions", "list", "--enabled"],
+ ["flatpak", "list", "--user", "--app"],
+ *[["flatpak", "info", "--user", app] for app in packages("flatpak")],
+ ["getent", "passwd", str(os.getuid())],
+ ["getsubids", os.environ.get("USER", "")],
+ ["getsubids", "-g", os.environ.get("USER", "")],
+ ]
+ failed = False
+ for command in commands:
+ print("\n> " + " ".join(command), flush=True)
+ try:
+ failed |= subprocess.run(command, check=False).returncode != 0
+ except FileNotFoundError:
+ print(f"Missing: {command[0]}")
+ failed = True
+ if failed:
+ raise SystemExit(1)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "action", choices=["install", "update", "flatpak-update", "extensions", "check"]
+ )
+ args = parser.parse_args()
+ require_canonical()
+ if args.action == "install":
+ install()
+ elif args.action == "check":
+ check()
+ else:
+ commands = update_commands()
+ if args.action == "flatpak-update":
+ commands = [
+ ["flatpak", "update", "--user", "--assumeyes", *packages("flatpak")]
+ ]
+ elif args.action == "extensions":
+ commands = [["gext", "install", *packages("extensions")]]
+ for command in commands:
+ subprocess.run(command, check=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/canonical_profiles.py b/scripts/canonical_profiles.py
new file mode 100644
index 0000000..4c12085
--- /dev/null
+++ b/scripts/canonical_profiles.py
@@ -0,0 +1,74 @@
+"""Add owned preferences to existing Snap browser profiles."""
+
+import configparser
+import json
+import shutil
+from pathlib import Path
+
+from scripts.canonical import ROOT, require_canonical
+
+START = "// dotfiles: begin"
+END = "// dotfiles: end"
+
+
+def preferences(existing: str, owned: str) -> str:
+ if START in existing:
+ before, remainder = existing.split(START, 1)
+ if END not in remainder:
+ raise ValueError("Incomplete dotfiles preference block")
+ _, after = remainder.split(END, 1)
+ existing = before.rstrip() + after
+ return existing.rstrip() + "\n" + START + "\n" + owned.rstrip() + "\n" + END + "\n"
+
+
+def profiles(root: Path) -> list[Path]:
+ ini = configparser.ConfigParser(interpolation=None)
+ ini.read(root / "profiles.ini")
+ result = []
+ for section in ini.sections():
+ if not section.startswith("Profile") or "Path" not in ini[section]:
+ continue
+ path = Path(ini[section]["Path"])
+ if ini[section].get("IsRelative", "1") == "1":
+ path = root / path
+ if path.resolve().is_relative_to(root.resolve()) and path.is_dir():
+ result.append(path)
+ return result
+
+
+def deploy(root: Path, source: Path) -> None:
+ found = profiles(root)
+ if not found:
+ print(f"No profile under {root}. Start the app once, close it, then retry.")
+ for profile in found:
+ target = profile / "user.js"
+ existing = target.read_text() if target.exists() else ""
+ updated = preferences(existing, source.read_text())
+ if updated == existing:
+ continue
+ if target.exists() and not target.with_suffix(".js.pre-dotfiles").exists():
+ shutil.copy2(target, target.with_suffix(".js.pre-dotfiles"))
+ target.write_text(updated)
+ print(f"Updated {target}")
+
+
+def main() -> None:
+ require_canonical()
+ home = Path.home()
+ deploy(home / "snap/firefox/common/.mozilla/firefox", ROOT / "canonical/firefox.js")
+ deploy(
+ home / "snap/thunderbird/common/.thunderbird", ROOT / "canonical/thunderbird.js"
+ )
+ source = (
+ home
+ / ".nix-profile/lib/mozilla/native-messaging-hosts/external_editor_revived.json"
+ )
+ manifest = json.loads(source.read_text())
+ target = home / ".mozilla/native-messaging-hosts/external_editor_revived.json"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ manifest["path"] = str(home / ".nix-profile/bin/external-editor-revived")
+ target.write_text(json.dumps(manifest, indent=2) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/maintenance-lib.sh b/scripts/maintenance-lib.sh
new file mode 100644
index 0000000..33e7e07
--- /dev/null
+++ b/scripts/maintenance-lib.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+
+# Set args and maintenance_run for one domain of a maintenance request.
+# shellcheck disable=SC2034
+_maintenance_select() {
+ local scope=$1 domain=$2 role raw target
+ shift 2
+ role=$(_machine_role) || return 1
+ args=()
+ maintenance_run=false
+ if [ "$scope" = host ]; then
+ _require_host || return 1
+ args=("$@")
+ maintenance_run=true
+ return
+ fi
+ for raw in "$@"; do
+ case "$raw" in
+ /etc/* | etc/*)
+ [ "$role" = host ] || {
+ echo 'error: /etc paths require the host role' >&2
+ return 1
+ }
+ target=etc
+ ;;
+ */*) target=home ;;
+ *)
+ echo "error: expected a file path: $raw" >&2
+ return 1
+ ;;
+ esac
+ [ "$target" != "$domain" ] || args+=("$raw")
+ done
+ if [ ${#args[@]} -gt 0 ] || { [ $# -eq 0 ] && { [ "$domain" = home ] || [ "$role" = host ]; }; }; then
+ maintenance_run=true
+ fi
+ return 0
+}
diff --git a/scripts/nix-daemon-update.sh b/scripts/nix-daemon-update.sh
new file mode 100644
index 0000000..2b2d12c
--- /dev/null
+++ b/scripts/nix-daemon-update.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ -e /nix/nix-installer || -e /etc/nix/nix.custom.conf ]] || dpkg-query -W nix-bin >/dev/null 2>&1; then
+ echo 'error: this recipe is only for the upstream multi-user installer' >&2
+ exit 1
+fi
+nix=/nix/var/nix/profiles/default/bin/nix
+[[ -x $nix ]]
+sudo "$nix" --extra-experimental-features nix-command upgrade-nix --profile /nix/var/nix/profiles/default
+sudo systemctl daemon-reload
+sudo systemctl restart nix-daemon.service
+"$nix" --version