Last active
July 23, 2026 02:20
-
-
Save andrewmcodes/dba977e072aebbe01f41a9db02881ee9 to your computer and use it in GitHub Desktop.
MM2 Conf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # install.sh — places the complete magicmirror-portrait repository. | |
| # | |
| # Usage: | |
| # bash install.sh [DEST] # default DEST: ./magicmirror-portrait | |
| # bash install.sh -f [DEST] # overwrite existing files in DEST | |
| # | |
| # The script writes every project file, then verifies each one against an | |
| # embedded SHA-256 checksum. It does NOT clone MagicMirror or install anything | |
| # else — see the generated README.md for those steps. | |
| set -euo pipefail | |
| FORCE=0 | |
| if [[ "${1:-}" == "-f" || "${1:-}" == "--force" ]]; then | |
| FORCE=1; shift | |
| fi | |
| DEST="${1:-magicmirror-portrait}" | |
| printf 'Placing magicmirror-portrait into: %s\n' "$DEST" | |
| # --- collision guard ------------------------------------------------------- | |
| FILES=( | |
| ".gitignore" | |
| "README.md" | |
| "config/config.js" | |
| "css/custom.css" | |
| "fnox.toml" | |
| "mise.toml" | |
| "modules/MMM-GitHubDashboard/MMM-GitHubDashboard.js" | |
| "modules/MMM-GitHubDashboard/node_helper.js" | |
| "setup.sh" | |
| "systemd/magicmirror.service" | |
| ) | |
| if [[ $FORCE -eq 0 ]]; then | |
| existing=() | |
| for rel in "${FILES[@]}"; do | |
| [[ -e "$DEST/$rel" ]] && existing+=("$rel") | |
| done | |
| if [[ ${#existing[@]} -gt 0 ]]; then | |
| printf '\nRefusing to overwrite existing files:\n' >&2 | |
| printf ' %s\n' "${existing[@]}" >&2 | |
| printf '\nRe-run with -f to overwrite.\n' >&2 | |
| exit 1 | |
| fi | |
| fi | |
| # --- directories ----------------------------------------------------------- | |
| mkdir -p "$DEST" | |
| mkdir -p "$DEST/config" | |
| mkdir -p "$DEST/css" | |
| mkdir -p "$DEST/modules/MMM-GitHubDashboard" | |
| mkdir -p "$DEST/systemd" | |
| # --- files ----------------------------------------------------------------- | |
| cat > "$DEST/.gitignore" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| # Vendored MagicMirror checkout (installed, not committed) | |
| /MagicMirror/ | |
| # Runtime cache written by the GitHub module | |
| modules/MMM-GitHubDashboard/cache.json | |
| # Never commit plaintext secrets or age keys | |
| .env | |
| *.key | |
| key.txt | |
| # Node | |
| node_modules/ | |
| npm-debug.log* | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/README.md" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| # Portrait MagicMirror² Dashboard | |
| A low-maintenance MagicMirror² setup for a Raspberry Pi 5 driving a 27" 4K | |
| monitor in **portrait** orientation (2160×3840). Dark, Apple-inspired, single | |
| centered column, mostly built-in modules. Node is managed by **mise**, secrets | |
| by **fnox**, autostart by a **systemd user service**. | |
| This repo is the source of truth. MagicMirror itself is cloned alongside it and | |
| the config/theme/module are symlinked in, so upgrading the app never touches | |
| your setup. | |
| ## Layout (top → bottom) | |
| 1. Large thin clock + date (hero) | |
| 2. Current weather with today's high/low | |
| 3. Compact 7-day forecast | |
| 4. Calendar — next 7 days across Apple + Google, including birthdays | |
| 5. GitHub summary — unread notifications, open PRs involving you, failing CI, and | |
| up to three actionable items | |
| 6. Rotating RSS newsfeed (bottom ticker) | |
| Weather uses **Open-Meteo**, which needs no API key — one less secret and one | |
| less thing to expire. Swap in OpenWeatherMap later if you prefer. | |
| ## Prerequisites | |
| - Raspberry Pi OS (Bookworm), Wayland/labwc session | |
| - [`mise`](https://mise.jdx.dev) installed and on `PATH` | |
| - `git` | |
| The repo is assumed to live at `~/mm`. Adjust `MM_HOME` in `mise.toml` and the | |
| paths in `systemd/magicmirror.service` if you put it elsewhere. | |
| ## Install | |
| ```bash | |
| # 1. Toolchain (Node 22 + fnox) | |
| cd ~/mm | |
| mise trust && mise install | |
| # 2. MagicMirror itself (vendored, git-ignored) | |
| git clone https://github.com/MagicMirrorOrg/MagicMirror.git ~/mm/MagicMirror | |
| cd ~/mm/MagicMirror && npm run install-mm && cd ~/mm | |
| # 3. Symlink this repo's config, theme and module into the checkout | |
| mise run link | |
| ``` | |
| ## Secrets (fnox) | |
| ```bash | |
| fnox init # generates the local age key (~/.config/fnox) | |
| fnox set GITHUB_TOKEN # fine-grained PAT, read-only (see below) | |
| fnox set ICAL_APPLE # iCloud "Public Calendar" URL | |
| fnox set ICAL_APPLE_BDAY # iCloud Birthdays URL (optional) | |
| fnox set ICAL_GOOGLE # Google "Secret address in iCal format" | |
| fnox set ICAL_GOOGLE_BDAY # Google Birthdays URL (optional) | |
| fnox list # confirm names are set (values stay hidden) | |
| ``` | |
| GitHub PAT scopes (read-only): **Notifications: Read**, **Pull requests: Read**, | |
| **Checks: Read**, **Metadata: Read**. Then set your handle in `config/config.js` | |
| (`username: "YOUR_GITHUB_USERNAME"`). | |
| The GitHub token is read **only** by the module's `node_helper` on the server | |
| and is never sent to the browser or written to logs. Calendar ICS URLs are | |
| consumed by the built-in calendar module and therefore do reach the local | |
| browser — use read-only share links and don't reuse anything sensitive. | |
| ## Validate | |
| ```bash | |
| mise run validate # runs MagicMirror's own js/check_config.js with secrets loaded | |
| ``` | |
| Expected: "doesn't contain syntax errors" and "modules structure ... doesn't | |
| contain errors". Validation covers the full config; the GitHub module is | |
| additive — if its folder or token is missing, MagicMirror logs a warning and the | |
| rest of the dashboard runs unaffected. | |
| ## Start (systemd user service) | |
| ```bash | |
| mkdir -p ~/.config/systemd/user | |
| ln -sf ~/mm/systemd/magicmirror.service ~/.config/systemd/user/magicmirror.service | |
| loginctl enable-linger "$USER" # start at boot without logging in | |
| systemctl --user daemon-reload | |
| systemctl --user enable --now magicmirror | |
| ``` | |
| This runs MagicMirror **serveronly** on `http://localhost:8080`. The display is a | |
| separate kiosk browser (below), which keeps the always-on service simple and | |
| crash-resilient. | |
| ### Display + portrait rotation | |
| The panel is physically rotated, so rotate at the compositor, not in CSS. Add to | |
| your labwc autostart (`~/.config/labwc/autostart`), using your real output name | |
| from `wlr-randr`: | |
| ```bash | |
| wlr-randr --output HDMI-A-1 --transform 90 & | |
| chromium-browser --kiosk --app=http://localhost:8080 \ | |
| --noerrdialogs --disable-infobars --incognito & | |
| ``` | |
| Use `--transform 270` if it rotates the wrong way. `kanshi` can make the | |
| rotation persistent across reboots/hotplug. | |
| ## Operate | |
| ```bash | |
| # Logs (follow) | |
| mise run logs | |
| # or: journalctl --user -u magicmirror -f -o cat | |
| # Restart after a config or theme change | |
| systemctl --user restart magicmirror | |
| # Status | |
| systemctl --user status magicmirror | |
| ``` | |
| ## Rollback | |
| Config, theme and the module are version-controlled, so rollback is a git | |
| operation plus a restart: | |
| ```bash | |
| # Revert the last change to a single file | |
| git checkout -- config/config.js && systemctl --user restart magicmirror | |
| # Or roll the whole repo back to the last known-good commit | |
| git log --oneline | |
| git revert <bad_commit> # keeps history, or: | |
| git reset --hard <good_commit> # discards local changes | |
| systemctl --user restart magicmirror | |
| ``` | |
| To roll back MagicMirror itself: | |
| ```bash | |
| cd ~/mm/MagicMirror && git checkout <previous_tag> && npm run install-mm | |
| systemctl --user restart magicmirror | |
| ``` | |
| ## Repository layout | |
| ``` | |
| mise.toml Node 22 + fnox; link/validate/start/logs tasks | |
| fnox.toml Secret references (no plaintext); safe to commit | |
| config/config.js Modules + portrait layout; reads calendar URLs from env | |
| css/custom.css Dark Apple-inspired theme (incl. GitHub module styles) | |
| modules/MMM-GitHubDashboard/ | |
| MMM-GitHubDashboard.js Browser side — renders counts/items/status; no token | |
| node_helper.js Server side — token, polling, timeout, cache, stale | |
| systemd/magicmirror.service serveronly via mise + fnox, auto-restart | |
| ``` | |
| ## GitHub module behavior | |
| - Polls no more often than every 5 minutes (floored on both sides). | |
| - Aborts each request after `requestTimeout` (default 8s). | |
| - Caches the last good result in memory and on disk | |
| (`modules/MMM-GitHubDashboard/cache.json`), so a restart paints immediately. | |
| - On any failure it keeps showing the cached data with a compact amber "Stale" | |
| status; with no cache it shows a single red error line. | |
| - Renders three counts plus at most three actionable items, prioritized: | |
| review requests → failing CI → mentions. | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/config/config.js" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| /* MagicMirror² configuration — portrait kitchen dashboard (2160×3840). | |
| * | |
| * Layout is a single centered column, top to bottom: | |
| * clock → current weather → 7-day forecast → calendar → GitHub → newsfeed. | |
| * | |
| * Secrets come from the environment (injected by `fnox exec --`); this file | |
| * never contains a token or a raw calendar URL. Weather uses Open-Meteo, which | |
| * needs no API key, so the only env-supplied values here are calendar ICS URLs. | |
| */ | |
| // --- Location ----------------------------------------------------------------- | |
| const LAT = 33.3062; // Chandler, AZ | |
| const LON = -111.8413; | |
| const TZ = "America/Phoenix"; | |
| // --- Calendars ---------------------------------------------------------------- | |
| // Build the calendar list from the environment, skipping any that are unset so | |
| // an empty variable never becomes a broken fetcher. Colors (not the built-in | |
| // symbol) are what visually separate the sources, matching the flat dark theme. | |
| const calendarSources = [ | |
| { env: "ICAL_APPLE", name: "Apple", symbol: "calendar", color: "#0A84FF" }, | |
| { env: "ICAL_APPLE_BDAY", name: "Birthdays", symbol: "gift", color: "#FF9F0A" }, | |
| { env: "ICAL_GOOGLE", name: "Google", symbol: "calendar", color: "#30D158" }, | |
| { env: "ICAL_GOOGLE_BDAY", name: "Birthdays", symbol: "gift", color: "#FF9F0A" }, | |
| ]; | |
| const calendars = calendarSources | |
| .filter((c) => (process.env[c.env] || "").trim().length > 0) | |
| .map((c) => ({ | |
| url: process.env[c.env].trim(), | |
| name: c.name, | |
| symbol: c.symbol, | |
| color: c.color, | |
| })); | |
| let config = { | |
| address: "127.0.0.1", // serveronly + local kiosk browser only | |
| port: 8080, | |
| ipWhitelist: ["127.0.0.1", "::1"], | |
| ipAllowlist: ["127.0.0.1", "::1"], | |
| language: "en", | |
| locale: "en-US", | |
| timeFormat: 12, | |
| units: "imperial", | |
| modules: [ | |
| // 1) Hero: large thin clock + date ------------------------------------- | |
| { | |
| module: "clock", | |
| position: "top_bar", | |
| config: { | |
| timezone: TZ, | |
| displaySeconds: false, | |
| timeFormat: 12, | |
| showPeriod: true, | |
| showPeriodUpper: true, | |
| clockBold: false, | |
| dateFormat: "dddd, MMMM D", | |
| }, | |
| }, | |
| // 2) Current conditions + today's high/low ----------------------------- | |
| { | |
| module: "weather", | |
| position: "top_center", | |
| config: { | |
| weatherProvider: "openmeteo", | |
| type: "current", | |
| lat: LAT, | |
| lon: LON, | |
| tempUnits: "imperial", | |
| windUnits: "imperial", | |
| degreeLabel: true, | |
| showHumidity: "none", | |
| showWindDirection: false, | |
| showFeelsLike: false, | |
| showSun: false, | |
| }, | |
| }, | |
| // 6) Compact 7-day forecast (placed under current, before calendar) ---- | |
| { | |
| module: "weather", | |
| position: "top_center", | |
| header: "7-Day Forecast", | |
| config: { | |
| weatherProvider: "openmeteo", | |
| type: "daily", | |
| lat: LAT, | |
| lon: LON, | |
| tempUnits: "imperial", | |
| maxNumberOfDays: 7, | |
| fade: false, | |
| colored: true, | |
| tableClass: "small", | |
| }, | |
| }, | |
| // 3) Calendar: next 7 days incl. birthdays ----------------------------- | |
| { | |
| module: "calendar", | |
| position: "top_center", | |
| header: "Next 7 Days", | |
| config: { | |
| maximumNumberOfDays: 7, | |
| maximumEntries: 30, | |
| fetchInterval: 15 * 60 * 1000, // 15 min | |
| timeFormat: "absolute", | |
| getRelative: 0, | |
| urgency: 0, | |
| fade: false, | |
| showLocation: false, | |
| wrapEvents: true, | |
| maxTitleLength: 40, | |
| calendars: calendars, | |
| }, | |
| }, | |
| // 4) GitHub dashboard (custom module; see modules/MMM-GitHubDashboard) -- | |
| { | |
| module: "MMM-GitHubDashboard", | |
| position: "top_center", | |
| header: "GitHub", | |
| config: { | |
| // NOTE: no token here. The node_helper reads GITHUB_TOKEN from the | |
| // environment; the browser never receives it. | |
| username: "YOUR_GITHUB_USERNAME", | |
| updateInterval: 5 * 60 * 1000, // 5 min (also floored server-side) | |
| requestTimeout: 8000, | |
| maxActionable: 3, | |
| }, | |
| }, | |
| // 5) Rotating newsfeed -------------------------------------------------- | |
| { | |
| module: "newsfeed", | |
| position: "bottom_bar", | |
| config: { | |
| feeds: [ | |
| { title: "AP", url: "https://feedx.net/rss/ap.xml" }, | |
| { title: "NPR", url: "https://feeds.npr.org/1001/rss.xml" }, | |
| { title: "BBC", url: "https://feeds.bbci.co.uk/news/world/rss.xml" }, | |
| ], | |
| showSourceTitle: true, | |
| showPublishDate: true, | |
| showDescription: false, | |
| showAsList: false, | |
| wrapTitle: true, | |
| updateInterval: 12 * 1000, // rotate headline every 12s | |
| reloadInterval: 10 * 60 * 1000, // refetch every 10 min | |
| maxNewsItems: 20, | |
| ignoreOldItems: true, | |
| ignoreOlderThan: 24 * 60 * 60 * 1000, | |
| broadcastNewsFeeds: false, | |
| }, | |
| }, | |
| ], | |
| }; | |
| /*************** DO NOT EDIT BELOW THIS LINE ***************/ | |
| if (typeof module !== "undefined") { | |
| module.exports = config; | |
| } | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/css/custom.css" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| /* custom.css — dark, Apple-inspired theme for a 2160×3840 portrait panel. | |
| * | |
| * Design thesis: the clock is the hero — oversized and ultra-thin. Everything | |
| * else is a quiet, hairline-bordered "material" card in a single centered | |
| * column. One accent (system blue) carries "now"; birthdays borrow the warm | |
| * orange defined in config.js. Sizes are scaled up for a 4K panel read from | |
| * across a room. | |
| */ | |
| :root { | |
| --bg: #000000; | |
| --ink: rgba(255, 255, 255, 0.92); /* primary text */ | |
| --ink-2: rgba(255, 255, 255, 0.55); /* secondary text */ | |
| --ink-3: rgba(255, 255, 255, 0.30); /* tertiary / labels */ | |
| --hairline: rgba(255, 255, 255, 0.10); | |
| --card: rgba(255, 255, 255, 0.04); | |
| --accent: #0a84ff; /* Apple system blue (dark) */ | |
| --warn: #ff9f0a; /* stale / birthdays */ | |
| --bad: #ff453a; /* errors / failing CI */ | |
| --good: #30d158; /* healthy CI */ | |
| --font: -apple-system, "SF Pro Display", "SF Pro Text", "Helvetica Neue", | |
| "Inter", "Roboto", system-ui, sans-serif; | |
| --col: 1500px; /* content column width */ | |
| --gap: 90px; /* vertical rhythm between cards */ | |
| } | |
| /* Base ---------------------------------------------------------------------- */ | |
| html { | |
| font-size: 100%; | |
| } | |
| body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font-family: var(--font); | |
| font-weight: 300; | |
| line-height: 1.35; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| /* Kill MagicMirror's default vignette/gradients for a flat OLED black. */ | |
| .region.fullscreen, | |
| body::before, | |
| body::after { | |
| background: none !important; | |
| } | |
| /* Center the whole column and give the bars breathing room. */ | |
| .region.top.bar { | |
| width: 100%; | |
| top: 130px; | |
| left: 0; | |
| right: 0; | |
| text-align: center; | |
| } | |
| .region.top.center { | |
| width: var(--col); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| top: 620px; /* clears the hero clock above */ | |
| text-align: center; | |
| } | |
| .region.bottom.bar { | |
| width: 100%; | |
| bottom: 80px; | |
| } | |
| /* Generic type helpers MagicMirror sprinkles around ------------------------- */ | |
| .normal, | |
| .dimmed, | |
| .bright { | |
| color: var(--ink); | |
| } | |
| .dimmed { | |
| color: var(--ink-3); | |
| } | |
| header, | |
| .module-header { | |
| color: var(--ink-3); | |
| font-size: 26px; | |
| font-weight: 500; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| border: none; | |
| padding: 0 0 18px 0; | |
| margin: 0; | |
| } | |
| /* Card treatment for the stacked data modules. The clock and newsfeed opt | |
| out (they are the hero and the ticker, not cards). */ | |
| .region.top.center > .module { | |
| background: var(--card); | |
| border: 1px solid var(--hairline); | |
| border-radius: 28px; | |
| padding: 46px 54px; | |
| margin: 0 auto var(--gap) auto; | |
| max-width: var(--col); | |
| box-sizing: border-box; | |
| backdrop-filter: blur(24px); | |
| -webkit-backdrop-filter: blur(24px); | |
| } | |
| /* 1) Clock — the hero ------------------------------------------------------- */ | |
| .module.clock { | |
| background: none; | |
| border: none; | |
| padding: 0; | |
| } | |
| .module.clock .clock-grid, | |
| .module.clock .clockCircle { | |
| margin: 0 auto; | |
| } | |
| .clock .time { | |
| font-size: 340px; | |
| font-weight: 200; | |
| letter-spacing: -0.02em; | |
| line-height: 0.95; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .clock .time sup { | |
| font-size: 90px; | |
| font-weight: 300; | |
| color: var(--ink-2); | |
| vertical-align: 34px; | |
| } | |
| .clock .date { | |
| font-size: 62px; | |
| font-weight: 300; | |
| color: var(--ink-2); | |
| margin-top: 6px; | |
| } | |
| /* 2) + 6) Weather ----------------------------------------------------------- */ | |
| .module.weather { | |
| font-size: 40px; | |
| } | |
| .module.weather .weathericon { | |
| font-size: 64px; | |
| } | |
| .module.weather .current .large { | |
| font-size: 96px; | |
| font-weight: 200; | |
| } | |
| /* Forecast rows: airy, tabular, muted day labels. */ | |
| .module.weather table.small { | |
| font-size: 40px; | |
| width: 100%; | |
| border-spacing: 0 14px; | |
| border-collapse: separate; | |
| } | |
| .module.weather table.small .day { | |
| color: var(--ink-2); | |
| text-align: left; | |
| font-weight: 400; | |
| } | |
| .module.weather table.small .align-right { | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .module.weather .max-temp { | |
| color: var(--ink); | |
| } | |
| .module.weather .min-temp { | |
| color: var(--ink-3); | |
| } | |
| /* 3) Calendar --------------------------------------------------------------- */ | |
| .module.calendar { | |
| font-size: 40px; | |
| } | |
| .calendar table { | |
| width: 100%; | |
| border-spacing: 0 20px; | |
| border-collapse: separate; | |
| } | |
| .calendar .symbol { | |
| color: var(--ink-2); | |
| padding-right: 24px; | |
| font-size: 34px; | |
| } | |
| .calendar .title { | |
| color: var(--ink); | |
| font-weight: 400; | |
| } | |
| .calendar .time, | |
| .calendar .date { | |
| color: var(--ink-2); | |
| font-variant-numeric: tabular-nums; | |
| padding-left: 24px; | |
| white-space: nowrap; | |
| } | |
| .calendar .today .title, | |
| .calendar .today .time { | |
| color: var(--accent); | |
| font-weight: 500; | |
| } | |
| /* 5) Newsfeed — bottom ticker ---------------------------------------------- */ | |
| .module.newsfeed { | |
| background: none; | |
| border: none; | |
| border-top: 1px solid var(--hairline); | |
| padding: 40px 120px 0 120px; | |
| text-align: center; | |
| } | |
| .newsfeed .newsfeed-source { | |
| color: var(--ink-3); | |
| font-size: 28px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .newsfeed .newsfeed-title { | |
| color: var(--ink); | |
| font-size: 44px; | |
| font-weight: 300; | |
| } | |
| /* 4) GitHub dashboard — styles live here so the module ships CSS-free ------- */ | |
| .MMM-GitHubDashboard { | |
| font-size: 40px; | |
| } | |
| .ghd-counts { | |
| display: flex; | |
| justify-content: space-around; | |
| gap: 40px; | |
| margin-bottom: 36px; | |
| } | |
| .ghd-counts .ghd-metric { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| min-width: 200px; | |
| } | |
| .ghd-metric .ghd-value { | |
| font-size: 92px; | |
| font-weight: 200; | |
| line-height: 1; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .ghd-metric .ghd-label { | |
| margin-top: 12px; | |
| color: var(--ink-3); | |
| font-size: 26px; | |
| letter-spacing: 0.10em; | |
| text-transform: uppercase; | |
| } | |
| .ghd-metric.ghd-bad .ghd-value { | |
| color: var(--bad); | |
| } | |
| .ghd-items { | |
| list-style: none; | |
| margin: 0; | |
| padding: 24px 0 0 0; | |
| border-top: 1px solid var(--hairline); | |
| text-align: left; | |
| } | |
| .ghd-items li { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 20px; | |
| padding: 16px 0; | |
| } | |
| .ghd-items .ghd-kind { | |
| flex: 0 0 auto; | |
| font-size: 24px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--ink-3); | |
| min-width: 200px; | |
| } | |
| .ghd-items .ghd-kind.ghd-review { | |
| color: var(--accent); | |
| } | |
| .ghd-items .ghd-kind.ghd-ci { | |
| color: var(--bad); | |
| } | |
| .ghd-items .ghd-text { | |
| flex: 1 1 auto; | |
| color: var(--ink); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .ghd-items .ghd-repo { | |
| color: var(--ink-3); | |
| font-size: 30px; | |
| } | |
| /* Compact, always-visible status line. Green dot = fresh, amber = stale. */ | |
| .ghd-status { | |
| margin-top: 28px; | |
| font-size: 26px; | |
| color: var(--ink-3); | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| } | |
| .ghd-status::before { | |
| content: ""; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--good); | |
| flex: 0 0 auto; | |
| } | |
| .ghd-status.ghd-stale { | |
| color: var(--warn); | |
| } | |
| .ghd-status.ghd-stale::before { | |
| background: var(--warn); | |
| } | |
| .ghd-status.ghd-error { | |
| color: var(--bad); | |
| } | |
| .ghd-status.ghd-error::before { | |
| background: var(--bad); | |
| } | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/fnox.toml" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| # fnox.toml — secret *references* only. Safe to commit. | |
| # | |
| # fnox loads these into the environment when you run `fnox exec -- <cmd>`. | |
| # The systemd unit and the mise tasks both go through `fnox exec`, so the | |
| # MagicMirror process sees them as plain environment variables. | |
| # | |
| # Nothing secret lives in this file. With the `age` provider below, encrypted | |
| # values would be stored inline (still safe to commit). Swap the provider block | |
| # for 1Password / AWS / Vault if you prefer a remote backend — the [secrets] | |
| # section stays the same. | |
| # | |
| # One-time setup: | |
| # mise install # gets node + fnox | |
| # fnox init # writes an age keypair to ~/.config/fnox | |
| # fnox set GITHUB_TOKEN # paste a fine-grained PAT (prompt is hidden) | |
| # fnox set ICAL_APPLE # iCloud "Public Calendar" webcal/https URL | |
| # fnox set ICAL_APPLE_BDAY # iCloud Birthdays calendar URL (optional) | |
| # fnox set ICAL_GOOGLE # Google "Secret address in iCal format" | |
| # fnox set ICAL_GOOGLE_BDAY # Google Birthdays calendar URL (optional) | |
| # | |
| # Verify without revealing values: fnox list | |
| # Verify the runtime environment: fnox exec -- node -e "console.log(!!process.env.GITHUB_TOKEN)" | |
| root = true | |
| [providers] | |
| # age = local encryption. `fnox init` generates the recipient/key for you and | |
| # rewrites this line. Committed values are ciphertext. | |
| age = { type = "age" } | |
| [secrets] | |
| # --- GitHub (server-side only, never sent to the browser) --------------------- | |
| # Fine-grained PAT. Read-only is enough: | |
| # Notifications: Read | Pull requests: Read | Checks: Read | |
| # Contents/Metadata: Read (metadata is mandatory) | |
| GITHUB_TOKEN = { provider = "age", value = "", description = "Fine-grained GitHub PAT (read-only)", if_missing = "warn" } | |
| # --- Calendars (ICS URLs; read-only share links) ----------------------------- | |
| # These are consumed by the built-in calendar module and are therefore visible | |
| # to the local browser. Treat them as low-value read-only links; do not reuse a | |
| # link that exposes anything you would not put on the kitchen wall. | |
| ICAL_APPLE = { provider = "age", value = "", description = "iCloud personal calendar ICS", if_missing = "warn" } | |
| ICAL_APPLE_BDAY = { provider = "age", value = "", description = "iCloud birthdays calendar ICS", if_missing = "warn" } | |
| ICAL_GOOGLE = { provider = "age", value = "", description = "Google personal calendar ICS", if_missing = "warn" } | |
| ICAL_GOOGLE_BDAY = { provider = "age", value = "", description = "Google birthdays calendar ICS", if_missing = "warn" } | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/mise.toml" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| # mise.toml — toolchain for the MagicMirror portrait dashboard. | |
| # | |
| # mise owns Node (MagicMirror v2.37 needs Node >=22.22.2 <23 || >=24) and the | |
| # fnox secrets CLI. Nothing here contains a secret; fnox injects those at run | |
| # time (see fnox.toml and systemd/magicmirror.service). | |
| [tools] | |
| node = "22" # latest 22.x LTS; satisfies MagicMirror's minimum | |
| fnox = "latest" # secrets front-end, injected via `fnox exec --` | |
| [env] | |
| # Where MagicMirror itself is checked out. Adjust once, everything else follows. | |
| MM_HOME = "{{ config_root }}/MagicMirror" | |
| # Point the browser/kiosk here. serveronly binds to this. | |
| MM_PORT = "8080" | |
| # --- Tasks -------------------------------------------------------------------- | |
| # Run these from the repo root. Each secret-consuming task is wrapped in | |
| # `fnox exec --` so GITHUB_TOKEN and the calendar URLs arrive as env vars and | |
| # never touch disk or git. | |
| [tasks.link] | |
| description = "Symlink this repo's config + modules into the MagicMirror checkout" | |
| run = """ | |
| set -euo pipefail | |
| ln -sf "{{ config_root }}/config/config.js" "$MM_HOME/config/config.js" | |
| ln -sf "{{ config_root }}/css/custom.css" "$MM_HOME/css/custom.css" | |
| ln -sfn "{{ config_root }}/modules/MMM-GitHubDashboard" "$MM_HOME/modules/MMM-GitHubDashboard" | |
| echo "Linked config.js, custom.css and MMM-GitHubDashboard into $MM_HOME" | |
| """ | |
| [tasks.validate] | |
| description = "Run MagicMirror's own config checker against config/config.js" | |
| dir = "{{ config_root }}" | |
| run = "fnox exec -- node \"$MM_HOME/js/check_config.js\"" | |
| [tasks.start] | |
| description = "Start MagicMirror in server-only mode with secrets injected" | |
| dir = "{{ config_root }}" | |
| run = "fnox exec -- node \"$MM_HOME/serveronly\"" | |
| [tasks.logs] | |
| description = "Tail the systemd journal for the mirror service" | |
| run = "journalctl --user -u magicmirror -f -o cat" | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/modules/MMM-GitHubDashboard/MMM-GitHubDashboard.js" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| /* global Module, Log */ | |
| /** | |
| * MMM-GitHubDashboard (browser side) | |
| * | |
| * This half never sees the token and never talks to GitHub. It sends its | |
| * (non-secret) config to the node_helper and renders whatever view-model the | |
| * helper sends back: three counts, up to `maxActionable` items, and a compact | |
| * always-visible status line (fresh / stale / error). | |
| * | |
| * All markup is built with DOM APIs + textContent so untrusted PR/notification | |
| * titles can never inject HTML. | |
| */ | |
| Module.register("MMM-GitHubDashboard", { | |
| defaults: { | |
| username: "", // required; set in config.js | |
| updateInterval: 5 * 60 * 1000, // floored to 5 min server-side too | |
| requestTimeout: 8000, | |
| maxActionable: 3, | |
| }, | |
| start() { | |
| this.viewModel = null; | |
| this.loaded = false; | |
| // Never send a token — the helper reads it from process.env itself. | |
| this.sendSocketNotification("GHD_SET_CONFIG", { | |
| identifier: this.identifier, | |
| username: this.config.username, | |
| // Enforce the 5-minute floor here as well as in the helper. | |
| updateInterval: Math.max(this.config.updateInterval, 5 * 60 * 1000), | |
| requestTimeout: this.config.requestTimeout, | |
| maxActionable: Math.min(Math.max(this.config.maxActionable, 0), 3), | |
| }); | |
| }, | |
| socketNotificationReceived(notification, payload) { | |
| if (!payload || payload.identifier !== this.identifier) return; | |
| if (notification === "GHD_DATA") { | |
| this.viewModel = payload; | |
| this.loaded = true; | |
| this.updateDom(300); | |
| } | |
| }, | |
| // ---- rendering ---------------------------------------------------------- | |
| getDom() { | |
| const root = document.createElement("div"); | |
| root.className = "MMM-GitHubDashboard"; | |
| if (!this.loaded) { | |
| const loading = document.createElement("div"); | |
| loading.className = "ghd-status"; | |
| loading.textContent = "Loading…"; | |
| root.appendChild(loading); | |
| return root; | |
| } | |
| const vm = this.viewModel; | |
| // Counts row (hidden only if we have literally nothing cached yet). | |
| if (vm.counts) { | |
| root.appendChild( | |
| this._counts([ | |
| ["notifications", vm.counts.notifications, "Unread", false], | |
| ["prs", vm.counts.openPRs, "Open PRs", false], | |
| ["ci", vm.counts.failingCI, "Failing CI", vm.counts.failingCI > 0], | |
| ]), | |
| ); | |
| } | |
| // Up to N actionable items. | |
| if (vm.items && vm.items.length) { | |
| root.appendChild(this._items(vm.items)); | |
| } | |
| root.appendChild(this._status(vm)); | |
| return root; | |
| }, | |
| _counts(metrics) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "ghd-counts"; | |
| for (const [key, value, label, bad] of metrics) { | |
| const cell = document.createElement("div"); | |
| cell.className = "ghd-metric" + (bad ? " ghd-bad" : ""); | |
| cell.dataset.key = key; | |
| const v = document.createElement("span"); | |
| v.className = "ghd-value"; | |
| v.textContent = value == null ? "—" : String(value); | |
| const l = document.createElement("span"); | |
| l.className = "ghd-label"; | |
| l.textContent = label; | |
| cell.appendChild(v); | |
| cell.appendChild(l); | |
| wrap.appendChild(cell); | |
| } | |
| return wrap; | |
| }, | |
| _items(items) { | |
| const ul = document.createElement("ul"); | |
| ul.className = "ghd-items"; | |
| for (const it of items) { | |
| const li = document.createElement("li"); | |
| const kind = document.createElement("span"); | |
| kind.className = | |
| "ghd-kind" + (it.kind === "review" ? " ghd-review" : it.kind === "ci" ? " ghd-ci" : ""); | |
| kind.textContent = it.kindLabel; | |
| const text = document.createElement("span"); | |
| text.className = "ghd-text"; | |
| text.textContent = it.title; | |
| const repo = document.createElement("span"); | |
| repo.className = "ghd-repo"; | |
| repo.textContent = it.repo || ""; | |
| li.appendChild(kind); | |
| li.appendChild(text); | |
| li.appendChild(repo); | |
| ul.appendChild(li); | |
| } | |
| return ul; | |
| }, | |
| _status(vm) { | |
| const s = document.createElement("div"); | |
| s.className = "ghd-status"; | |
| if (vm.error && !vm.counts) { | |
| // No data at all to show. | |
| s.classList.add("ghd-error"); | |
| s.textContent = vm.error; | |
| return s; | |
| } | |
| if (vm.stale || vm.error) { | |
| s.classList.add(vm.error ? "ghd-error" : "ghd-stale"); | |
| const ago = this._ago(vm.lastSuccess); | |
| s.textContent = (vm.error ? "GitHub unavailable" : "Stale") + (ago ? ` · updated ${ago}` : ""); | |
| return s; | |
| } | |
| const ago = this._ago(vm.lastSuccess); | |
| s.textContent = ago ? `Updated ${ago}` : "Updated just now"; | |
| return s; | |
| }, | |
| _ago(ts) { | |
| if (!ts) return ""; | |
| const mins = Math.max(0, Math.round((Date.now() - ts) / 60000)); | |
| if (mins < 1) return "just now"; | |
| if (mins < 60) return `${mins}m ago`; | |
| const hrs = Math.round(mins / 60); | |
| return `${hrs}h ago`; | |
| }, | |
| }); | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/modules/MMM-GitHubDashboard/node_helper.js" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| /* MMM-GitHubDashboard — node_helper (server side) | |
| * | |
| * The token lives ONLY here. It is read from process.env.GITHUB_TOKEN, used to | |
| * build request headers, and never sent to the browser and never logged. The | |
| * browser receives a small view-model (counts + up to N items + status). | |
| * | |
| * Behaviour required of this helper: | |
| * - poll no more often than every 5 minutes (floored below) | |
| * - abort each request after a timeout | |
| * - cache the last successful result (in memory + on disk) | |
| * - keep showing stale data when GitHub is unavailable | |
| * - never log the token or authenticated request headers | |
| */ | |
| const NodeHelper = require("node_helper"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const API = "https://api.github.com"; | |
| const MIN_INTERVAL = 5 * 60 * 1000; // hard floor: 5 minutes | |
| const CACHE_FILE = path.join(__dirname, "cache.json"); | |
| module.exports = NodeHelper.create({ | |
| start() { | |
| this.configs = {}; // identifier -> config (no token) | |
| this.timers = {}; // identifier -> interval handle | |
| this.cache = this._loadCache(); // identifier -> last good view-model | |
| console.log("[MMM-GitHubDashboard] helper started"); | |
| }, | |
| socketNotificationReceived(notification, payload) { | |
| if (notification !== "GHD_SET_CONFIG" || !payload || !payload.identifier) return; | |
| const id = payload.identifier; | |
| this.configs[id] = { | |
| username: (payload.username || "").trim(), | |
| interval: Math.max(Number(payload.updateInterval) || MIN_INTERVAL, MIN_INTERVAL), | |
| timeout: Math.max(Number(payload.requestTimeout) || 8000, 1000), | |
| maxActionable: Math.min(Math.max(Number(payload.maxActionable) || 3, 0), 3), | |
| }; | |
| // If we already have cached data, paint it immediately (great after a | |
| // restart or when GitHub is down at boot). | |
| if (this.cache[id]) { | |
| this._send(id, { ...this.cache[id], stale: true }); | |
| } | |
| // (Re)start the poll loop for this instance. | |
| if (this.timers[id]) clearInterval(this.timers[id]); | |
| this._poll(id); | |
| this.timers[id] = setInterval(() => this._poll(id), this.configs[id].interval); | |
| }, | |
| // ---- polling ------------------------------------------------------------ | |
| async _poll(id) { | |
| const cfg = this.configs[id]; | |
| if (!cfg) return; | |
| const token = process.env.GITHUB_TOKEN; | |
| if (!token) { | |
| // Deliberately do not fabricate data; surface a clear, compact state. | |
| return this._fail(id, "GITHUB_TOKEN not set"); | |
| } | |
| if (!cfg.username) { | |
| return this._fail(id, "username not configured"); | |
| } | |
| // Headers are built locally and never logged. | |
| const headers = { | |
| Authorization: `Bearer ${token}`, | |
| Accept: "application/vnd.github+json", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| "User-Agent": "MMM-GitHubDashboard", | |
| }; | |
| const user = cfg.username; | |
| const q = (s) => `${API}/search/issues?q=${encodeURIComponent(s)}&per_page=5`; | |
| try { | |
| const [notifications, openPRs, reviews, failing] = await Promise.all([ | |
| this._getJson(`${API}/notifications?all=false&per_page=50`, headers, cfg.timeout), | |
| this._getJson(q(`is:open is:pr involves:${user}`), headers, cfg.timeout), | |
| this._getJson(q(`is:open is:pr review-requested:${user}`), headers, cfg.timeout), | |
| this._getJson(q(`is:open is:pr author:${user} status:failure`), headers, cfg.timeout), | |
| ]); | |
| const vm = this._buildViewModel(cfg, { notifications, openPRs, reviews, failing }); | |
| this.cache[id] = vm; | |
| this._saveCache(); | |
| this._send(id, vm); | |
| } catch (err) { | |
| // err.message is safe (HTTP status / abort / DNS); it never contains the | |
| // token or headers. Do NOT log the request options. | |
| this._fail(id, this._reason(err)); | |
| } | |
| }, | |
| _buildViewModel(cfg, { notifications, openPRs, reviews, failing }) { | |
| const notifCount = Array.isArray(notifications) ? notifications.length : 0; | |
| // Actionable items, prioritised: review requests, then failing CI, then | |
| // notification mentions. Deduped by URL, capped at maxActionable. | |
| const items = []; | |
| const seen = new Set(); | |
| const push = (kind, kindLabel, title, url, repo) => { | |
| if (!url || seen.has(url) || items.length >= cfg.maxActionable) return; | |
| seen.add(url); | |
| items.push({ kind, kindLabel, title: title || "(untitled)", url, repo }); | |
| }; | |
| for (const it of reviews.items || []) { | |
| push("review", "Review", it.title, it.html_url, this._repo(it)); | |
| } | |
| for (const it of failing.items || []) { | |
| push("ci", "CI failing", it.title, it.html_url, this._repo(it)); | |
| } | |
| for (const n of notifications || []) { | |
| if (["review_requested", "mention", "assign"].includes(n.reason)) { | |
| const url = (n.subject && n.subject.url) || (n.repository && n.repository.html_url); | |
| push("mention", "Mention", n.subject && n.subject.title, url, n.repository && n.repository.full_name); | |
| } | |
| } | |
| return { | |
| counts: { | |
| notifications: notifCount >= 50 ? "50+" : notifCount, | |
| openPRs: typeof openPRs.total_count === "number" ? openPRs.total_count : 0, | |
| failingCI: typeof failing.total_count === "number" ? failing.total_count : 0, | |
| }, | |
| items, | |
| stale: false, | |
| error: null, | |
| lastSuccess: Date.now(), | |
| }; | |
| }, | |
| // Serve cached data as stale if we have it; otherwise a bare error state. | |
| _fail(id, reason) { | |
| if (this.cache[id]) { | |
| this._send(id, { ...this.cache[id], stale: true, error: reason }); | |
| } else { | |
| this._send(id, { | |
| counts: null, | |
| items: [], | |
| stale: false, | |
| error: reason, | |
| lastSuccess: null, | |
| }); | |
| } | |
| }, | |
| _send(id, vm) { | |
| this.sendSocketNotification("GHD_DATA", { identifier: id, ...vm }); | |
| }, | |
| // ---- fetch with timeout/abort ------------------------------------------ | |
| async _getJson(url, headers, timeoutMs) { | |
| // AbortSignal.timeout aborts the request after timeoutMs (Node 18+). | |
| const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); | |
| if (!res.ok) { | |
| const err = new Error(`GitHub ${res.status}`); | |
| err.status = res.status; | |
| throw err; | |
| } | |
| return res.json(); | |
| }, | |
| _reason(err) { | |
| if (err && err.name === "TimeoutError") return "GitHub timed out"; | |
| if (err && err.status) return `GitHub error ${err.status}`; | |
| return "GitHub unreachable"; | |
| }, | |
| _repo(issue) { | |
| // repository_url looks like https://api.github.com/repos/owner/name | |
| if (issue.repository_url) return issue.repository_url.split("/repos/")[1] || ""; | |
| if (issue.html_url) { | |
| const m = issue.html_url.match(/github\.com\/([^/]+\/[^/]+)/); | |
| return m ? m[1] : ""; | |
| } | |
| return ""; | |
| }, | |
| // ---- disk cache --------------------------------------------------------- | |
| _loadCache() { | |
| try { | |
| return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); | |
| } catch { | |
| return {}; | |
| } | |
| }, | |
| _saveCache() { | |
| try { | |
| fs.writeFileSync(CACHE_FILE, JSON.stringify(this.cache), "utf8"); | |
| } catch (e) { | |
| console.log("[MMM-GitHubDashboard] could not persist cache:", e.message); | |
| } | |
| }, | |
| }); | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/setup.sh" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| #!/usr/bin/env bash | |
| # | |
| # setup.sh — one command to provision the MagicMirror portrait dashboard. | |
| # | |
| # Assumes: mise + Homebrew already installed, Raspberry Pi OS (Bookworm) with a | |
| # Wayland/labwc session. Run it from inside this repo: | |
| # | |
| # ./setup.sh | |
| # | |
| # What it does (each step is idempotent and safe to re-run): | |
| # 1. mise trust + install -> Node 22 and the fnox secrets CLI | |
| # 2. age keypair -> ~/.config/fnox/age.txt (+ recipient into fnox.toml) | |
| # 3. GitHub username -> written into config/config.js | |
| # 4. clone MagicMirror -> <repo>/MagicMirror at a pinned tag | |
| # 5. npm install (headless) -> serveronly deps, electron binary skipped | |
| # 6. mise run link -> symlink config/css/module into the checkout | |
| # 7. secrets (optional) -> prompt for GITHUB_TOKEN + calendar URLs | |
| # 8. mise run validate -> MagicMirror's own config checker | |
| # 9. systemd --user service -> enable-linger + enable --now | |
| # 10. kiosk (optional) -> labwc autostart: rotate + Chromium kiosk | |
| # | |
| # Nothing secret is ever written into the repo. The GitHub token stays in the | |
| # age-encrypted fnox store and is injected only at run time via `fnox exec`. | |
| # | |
| # Flags: | |
| # -y, --yes non-interactive; accept defaults, skip secret prompts | |
| # --username NAME set the GitHub username without prompting | |
| # --kiosk configure the labwc kiosk/rotation step | |
| # --no-kiosk skip the kiosk step (default when non-interactive) | |
| # --mm-tag vX.Y.Z MagicMirror tag to clone (default: v2.37.0) | |
| # --output NAME Wayland output for rotation (e.g. HDMI-A-1) | |
| # -h, --help | |
| set -euo pipefail | |
| # --------------------------------------------------------------------------- | |
| # Defaults / flags | |
| # --------------------------------------------------------------------------- | |
| MM_TAG_DEFAULT="v2.37.0" | |
| MM_REPO_URL="https://github.com/MagicMirrorOrg/MagicMirror.git" | |
| ROTATE_TRANSFORM="90" # portrait: 90 = clockwise. Use 270 to flip. | |
| ASSUME_YES=0 | |
| DO_KIOSK="ask" # ask | yes | no | |
| GH_USERNAME="${GH_USERNAME:-}" | |
| MM_TAG="$MM_TAG_DEFAULT" | |
| KIOSK_OUTPUT="${KIOSK_OUTPUT:-}" | |
| # --------------------------------------------------------------------------- | |
| # Pretty output | |
| # --------------------------------------------------------------------------- | |
| if [[ -t 1 ]]; then | |
| C_B=$'\033[1;34m'; C_G=$'\033[1;32m'; C_Y=$'\033[1;33m'; C_R=$'\033[1;31m'; C_0=$'\033[0m' | |
| else | |
| C_B=""; C_G=""; C_Y=""; C_R=""; C_0="" | |
| fi | |
| step() { printf '\n%s==>%s %s\n' "$C_B" "$C_0" "$*"; } | |
| ok() { printf '%s ok%s %s\n' "$C_G" "$C_0" "$*"; } | |
| warn() { printf '%swarn%s %s\n' "$C_Y" "$C_0" "$*" >&2; } | |
| die() { printf '%serror%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } | |
| have() { command -v "$1" >/dev/null 2>&1; } | |
| # =========================================================================== | |
| # Pure helpers (unit-tested; see the lib-only guard below) | |
| # =========================================================================== | |
| # Ensure the age provider in fnox.toml carries our recipient public key. | |
| # Handles the shipped state `age = { type = "age" }` and is idempotent. | |
| inject_age_recipient() { | |
| local file="$1" pub="$2" | |
| [[ -f "$file" ]] || { echo "inject_age_recipient: no $file" >&2; return 1; } | |
| if grep -q "$pub" "$file"; then | |
| return 0 # already present | |
| fi | |
| if grep -Eq '^[[:space:]]*age[[:space:]]*=.*recipients' "$file"; then | |
| echo "RECIPIENTS_EXIST" # someone else configured it | |
| return 2 | |
| fi | |
| # Turn `age = { type = "age" }` into the same with a recipients array. | |
| sed -i.bak -E \ | |
| "s|^([[:space:]]*age[[:space:]]*=[[:space:]]*\{[[:space:]]*type[[:space:]]*=[[:space:]]*\"age\")[[:space:]]*\}|\1, recipients = [\"$pub\"] }|" \ | |
| "$file" | |
| rm -f "$file.bak" | |
| grep -q "$pub" "$file" | |
| } | |
| # Replace the username placeholder in config.js. Idempotent. | |
| set_github_username() { | |
| local file="$1" user="$2" | |
| [[ -f "$file" ]] || { echo "set_github_username: no $file" >&2; return 1; } | |
| [[ "$user" =~ ^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$ ]] || { | |
| echo "invalid GitHub username: $user" >&2; return 1; } | |
| if grep -q "YOUR_GITHUB_USERNAME" "$file"; then | |
| sed -i.bak "s/YOUR_GITHUB_USERNAME/$user/g" "$file"; rm -f "$file.bak" | |
| return 0 | |
| fi | |
| echo "ALREADY_SET"; return 0 # placeholder already replaced | |
| } | |
| # Emit a systemd user unit with absolute paths resolved at install time. | |
| render_unit() { | |
| local mise_bin="$1" repo="$2" mm_home="$3" age_env="$4" | |
| cat <<UNIT | |
| # MagicMirror2 portrait dashboard (serveronly) — generated by setup.sh | |
| # Regenerate with: ./setup.sh (this file is overwritten, so edit setup.sh) | |
| [Unit] | |
| Description=MagicMirror2 portrait dashboard (serveronly) | |
| Documentation=https://docs.magicmirror.builders | |
| After=network-online.target | |
| Wants=network-online.target | |
| [Service] | |
| Type=simple | |
| WorkingDirectory=$repo | |
| # age.env supplies FNOX_AGE_KEY so fnox can decrypt secrets headlessly. | |
| # The leading '-' makes it optional: the mirror still starts if it is absent. | |
| EnvironmentFile=-$age_env | |
| Environment=NODE_ENV=production | |
| # mise puts node+fnox on PATH; fnox injects secrets; MagicMirror runs headless. | |
| ExecStart=$mise_bin exec -- fnox exec -- node $mm_home/serveronly | |
| Restart=always | |
| RestartSec=5 | |
| TimeoutStopSec=15 | |
| StandardOutput=journal | |
| StandardError=journal | |
| NoNewPrivileges=true | |
| ProtectControlGroups=true | |
| ProtectKernelTunables=true | |
| [Install] | |
| WantedBy=default.target | |
| UNIT | |
| } | |
| # Write an idempotent, marker-delimited block into a labwc autostart file, | |
| # backing up any existing file first. | |
| install_autostart_block() { | |
| local file="$1" output="$2" transform="$3" url="$4" | |
| local begin="# >>> magicmirror-portrait >>>" | |
| local end="# <<< magicmirror-portrait <<<" | |
| local rotate_line | |
| if [[ -n "$output" ]]; then | |
| rotate_line="wlr-randr --output $output --transform $transform &" | |
| else | |
| rotate_line="# wlr-randr --output <YOUR-OUTPUT> --transform $transform & # set --output (run: wlr-randr)" | |
| fi | |
| local block | |
| block="$begin | |
| # Screen rotation + Chromium kiosk for the MagicMirror dashboard. | |
| $rotate_line | |
| chromium-browser --kiosk --noerrdialogs --disable-infobars --incognito \\ | |
| --check-for-update-interval=31536000 \"$url\" & | |
| $end" | |
| mkdir -p "$(dirname "$file")" | |
| if [[ -f "$file" ]] && grep -qF "$begin" "$file"; then | |
| # Replace the existing managed block in place. | |
| local tmp; tmp="$(mktemp)" | |
| awk -v b="$begin" -v e="$end" -v repl="$block" ' | |
| $0==b {print repl; skip=1; next} | |
| $0==e {skip=0; next} | |
| !skip {print} | |
| ' "$file" > "$tmp" | |
| mv "$tmp" "$file" | |
| else | |
| [[ -f "$file" ]] && cp -p "$file" "$file.bak.$(date +%Y%m%d%H%M%S)" | |
| { [[ -f "$file" ]] && echo; echo "$block"; } >> "$file" | |
| fi | |
| chmod +x "$file" 2>/dev/null || true | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Lib-only guard: `source setup.sh` with MM_SETUP_LIB_ONLY=1 defines the | |
| # helpers above and stops here, so they can be tested in isolation. | |
| # --------------------------------------------------------------------------- | |
| if [[ "${MM_SETUP_LIB_ONLY:-0}" == "1" ]]; then | |
| return 0 2>/dev/null || exit 0 | |
| fi | |
| # =========================================================================== | |
| # Argument parsing | |
| # =========================================================================== | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -y|--yes) ASSUME_YES=1; [[ "$DO_KIOSK" == "ask" ]] && DO_KIOSK="no"; shift ;; | |
| --username) GH_USERNAME="${2:-}"; shift 2 ;; | |
| --kiosk) DO_KIOSK="yes"; shift ;; | |
| --no-kiosk) DO_KIOSK="no"; shift ;; | |
| --mm-tag) MM_TAG="${2:-}"; shift 2 ;; | |
| --output) KIOSK_OUTPUT="${2:-}"; shift 2 ;; | |
| -h|--help) sed -n '2,40p' "$0"; exit 0 ;; | |
| *) die "unknown argument: $1" ;; | |
| esac | |
| done | |
| ask() { # ask "prompt" "default" -> echoes answer; auto-answers default with -y | |
| local prompt="$1" def="${2:-}" | |
| if [[ $ASSUME_YES -eq 1 ]]; then echo "$def"; return; fi | |
| local ans; read -r -p "$prompt" ans || true | |
| echo "${ans:-$def}" | |
| } | |
| # =========================================================================== | |
| # Resolve paths | |
| # =========================================================================== | |
| REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| cd "$REPO" | |
| [[ -f "$REPO/config/config.js" && -f "$REPO/mise.toml" ]] || \ | |
| die "run this from inside the repo (config/config.js + mise.toml not found)" | |
| MM_HOME="$REPO/MagicMirror" | |
| FNOX_DIR="$HOME/.config/fnox" | |
| AGE_FILE="$FNOX_DIR/age.txt" | |
| AGE_ENV="$FNOX_DIR/age.env" | |
| UNIT_DIR="$HOME/.config/systemd/user" | |
| UNIT_FILE="$UNIT_DIR/magicmirror.service" | |
| printf '%sMagicMirror portrait — setup%s\n' "$C_B" "$C_0" | |
| printf 'repo: %s\n' "$REPO" | |
| printf 'MagicMirror: %s (%s)\n' "$MM_HOME" "$MM_TAG" | |
| # =========================================================================== | |
| # 0. Preflight | |
| # =========================================================================== | |
| step "Preflight" | |
| [[ "$(uname -s)" == "Linux" ]] || warn "not Linux — the systemd/kiosk steps are Pi-specific" | |
| have mise || die "mise not found on PATH. Install it first: https://mise.jdx.dev" | |
| have git || die "git not found. Install with: brew install git (or apt install git)" | |
| MISE_BIN="$(command -v mise)" | |
| ok "mise at $MISE_BIN" | |
| # =========================================================================== | |
| # 1. Toolchain | |
| # =========================================================================== | |
| step "Installing toolchain (Node 22 + fnox) via mise" | |
| mise trust "$REPO" >/dev/null 2>&1 || true | |
| mise install | |
| mrun() { mise exec -- "$@"; } # run a tool through mise | |
| mrun node --version >/dev/null || die "node not available after mise install" | |
| mrun fnox --version >/dev/null || die "fnox not available after mise install" | |
| ok "node $(mrun node --version) · fnox $(mrun fnox --version 2>/dev/null | head -n1)" | |
| # =========================================================================== | |
| # 2. age key + fnox recipient | |
| # =========================================================================== | |
| step "Setting up the age encryption key for fnox" | |
| if ! have age-keygen; then | |
| warn "age-keygen missing — installing 'age' via Homebrew" | |
| have brew || die "Homebrew not found and age-keygen missing. Install age manually." | |
| brew install age | |
| fi | |
| mkdir -p "$FNOX_DIR"; chmod 700 "$FNOX_DIR" | |
| if [[ ! -f "$AGE_FILE" ]]; then | |
| age-keygen -o "$AGE_FILE" >/dev/null 2>&1 | |
| chmod 600 "$AGE_FILE" | |
| ok "generated $AGE_FILE" | |
| else | |
| ok "reusing existing $AGE_FILE" | |
| fi | |
| AGE_PUB="$(grep -m1 'public key:' "$AGE_FILE" | awk '{print $NF}')" | |
| AGE_SEC="$(grep -m1 '^AGE-SECRET-KEY-' "$AGE_FILE")" | |
| [[ -n "$AGE_PUB" && -n "$AGE_SEC" ]] || die "could not read keys from $AGE_FILE" | |
| # Runtime decryption key for the headless service + this script. | |
| printf 'FNOX_AGE_KEY=%s\n' "$AGE_SEC" > "$AGE_ENV"; chmod 600 "$AGE_ENV" | |
| export FNOX_AGE_KEY="$AGE_SEC" | |
| if inject_age_recipient "$REPO/fnox.toml" "$AGE_PUB"; then | |
| ok "fnox.toml recipient set ($AGE_PUB)" | |
| else | |
| rc=$? | |
| [[ $rc -eq 2 ]] && warn "fnox.toml already has recipients; ensure it includes $AGE_PUB" | |
| fi | |
| # =========================================================================== | |
| # 3. GitHub username | |
| # =========================================================================== | |
| step "GitHub username for the dashboard module" | |
| if [[ -z "$GH_USERNAME" ]]; then | |
| if grep -q "YOUR_GITHUB_USERNAME" "$REPO/config/config.js"; then | |
| GH_USERNAME="$(ask 'GitHub username (enter to set later): ' '')" | |
| fi | |
| fi | |
| if [[ -n "$GH_USERNAME" ]]; then | |
| if out="$(set_github_username "$REPO/config/config.js" "$GH_USERNAME")"; then | |
| [[ "$out" == "ALREADY_SET" ]] && ok "username already set (leaving as-is)" \ | |
| || ok "username set to $GH_USERNAME" | |
| else | |
| warn "could not set username automatically; edit config/config.js by hand" | |
| fi | |
| else | |
| warn "username left as placeholder — set it in config/config.js before first run" | |
| fi | |
| # =========================================================================== | |
| # 4. Clone MagicMirror | |
| # =========================================================================== | |
| step "Fetching MagicMirror $MM_TAG" | |
| if [[ -f "$MM_HOME/serveronly" ]]; then | |
| ok "MagicMirror already present at $MM_HOME" | |
| else | |
| git clone --depth 1 --branch "$MM_TAG" "$MM_REPO_URL" "$MM_HOME" | |
| ok "cloned into $MM_HOME" | |
| fi | |
| # =========================================================================== | |
| # 5. Install dependencies (headless — no Electron binary needed) | |
| # =========================================================================== | |
| step "Installing MagicMirror dependencies (serveronly)" | |
| ( cd "$MM_HOME" && ELECTRON_SKIP_BINARY_DOWNLOAD=1 mise exec -- npm install --no-audit --no-fund ) | |
| ok "dependencies installed" | |
| # =========================================================================== | |
| # 6. Link repo config/module into the checkout | |
| # =========================================================================== | |
| step "Linking config, theme and module into the checkout" | |
| mise run link | |
| ok "linked" | |
| # =========================================================================== | |
| # 7. Secrets (optional, interactive) | |
| # =========================================================================== | |
| step "Secrets (stored age-encrypted in fnox; never in the repo)" | |
| setsecret() { # setsecret NAME "description" | |
| local name="$1" desc="$2" | |
| local a; a="$(ask " set $name ($desc)? [y/N/skip-all]: " 'n')" | |
| case "$a" in | |
| s|skip|skip-all) return 9 ;; | |
| y|Y|yes) mise exec -- fnox set "$name" --provider age ;; | |
| *) return 0 ;; | |
| esac | |
| } | |
| if [[ $ASSUME_YES -eq 1 ]]; then | |
| warn "non-interactive: skipping secrets. Set them later, e.g.:" | |
| warn " export FNOX_AGE_KEY=\$(grep AGE-SECRET-KEY $AGE_FILE)" | |
| warn " mise exec -- fnox set GITHUB_TOKEN --provider age" | |
| else | |
| echo " (fnox prompts hidden; press enter at each to skip, or type 's' to skip all)" | |
| for pair in \ | |
| "GITHUB_TOKEN|fine-grained PAT, read-only" \ | |
| "ICAL_APPLE|iCloud calendar ICS URL" \ | |
| "ICAL_APPLE_BDAY|iCloud birthdays ICS URL" \ | |
| "ICAL_GOOGLE|Google calendar ICS URL" \ | |
| "ICAL_GOOGLE_BDAY|Google birthdays ICS URL"; do | |
| rc=0; setsecret "${pair%%|*}" "${pair#*|}" || rc=$? | |
| if [[ $rc -eq 9 ]]; then warn "skipping remaining secrets"; break; fi | |
| if [[ $rc -ne 0 ]]; then warn "fnox set ${pair%%|*} failed (rc=$rc) — continuing"; fi | |
| done | |
| fi | |
| # =========================================================================== | |
| # 8. Validate config | |
| # =========================================================================== | |
| step "Validating config against MagicMirror's own checker" | |
| if mise run validate; then | |
| ok "config valid" | |
| else | |
| warn "validator reported issues — review the output above" | |
| fi | |
| # =========================================================================== | |
| # 9. systemd user service | |
| # =========================================================================== | |
| step "Installing the systemd --user service" | |
| if ! systemctl --user show-environment >/dev/null 2>&1; then | |
| warn "no systemd user session detected (are you on the Pi's own session?)." | |
| warn "Unit written to $UNIT_FILE; enable it later with:" | |
| warn " loginctl enable-linger \"$USER\" && systemctl --user daemon-reload && systemctl --user enable --now magicmirror" | |
| mkdir -p "$UNIT_DIR" | |
| render_unit "$MISE_BIN" "$REPO" "$MM_HOME" "$AGE_ENV" > "$UNIT_FILE" | |
| else | |
| mkdir -p "$UNIT_DIR" | |
| render_unit "$MISE_BIN" "$REPO" "$MM_HOME" "$AGE_ENV" > "$UNIT_FILE" | |
| loginctl enable-linger "$USER" >/dev/null 2>&1 || warn "could not enable linger (need it to run at boot)" | |
| systemctl --user daemon-reload | |
| systemctl --user enable --now magicmirror | |
| sleep 2 | |
| if systemctl --user is-active --quiet magicmirror; then | |
| ok "magicmirror.service is active" | |
| else | |
| warn "service not active yet — check: journalctl --user -u magicmirror -e" | |
| fi | |
| fi | |
| # =========================================================================== | |
| # 10. Kiosk + rotation (optional) | |
| # =========================================================================== | |
| step "Display kiosk + rotation (labwc autostart)" | |
| want_kiosk="$DO_KIOSK" | |
| if [[ "$want_kiosk" == "ask" ]]; then | |
| a="$(ask ' configure Chromium kiosk + screen rotation now? [y/N]: ' 'n')" | |
| [[ "$a" =~ ^[yY] ]] && want_kiosk="yes" || want_kiosk="no" | |
| fi | |
| if [[ "$want_kiosk" == "yes" ]]; then | |
| # Try to auto-detect the output name if we're in a Wayland session. | |
| if [[ -z "$KIOSK_OUTPUT" ]] && have wlr-randr; then | |
| KIOSK_OUTPUT="$(wlr-randr 2>/dev/null | awk 'NR==1{print $1}')" | |
| fi | |
| autostart="$HOME/.config/labwc/autostart" | |
| install_autostart_block "$autostart" "$KIOSK_OUTPUT" "$ROTATE_TRANSFORM" "http://localhost:8080" | |
| if [[ -n "$KIOSK_OUTPUT" ]]; then | |
| ok "labwc autostart updated ($autostart), output=$KIOSK_OUTPUT transform=$ROTATE_TRANSFORM" | |
| else | |
| warn "labwc autostart written but output name unknown." | |
| warn "Run 'wlr-randr' on the Pi, then edit $autostart (or re-run with --output NAME)." | |
| fi | |
| warn "log out/in (or reboot) for labwc autostart to take effect." | |
| else | |
| ok "skipped. To do it later: ./setup.sh --kiosk [--output HDMI-A-1]" | |
| fi | |
| # =========================================================================== | |
| # Done | |
| # =========================================================================== | |
| printf '\n%sSetup complete.%s\n' "$C_G" "$C_0" | |
| cat <<DONE | |
| Service: systemctl --user status magicmirror | |
| Logs: mise run logs (or: journalctl --user -u magicmirror -f) | |
| Restart: systemctl --user restart magicmirror | |
| Validate: mise run validate | |
| Secrets later (run from this repo): | |
| export FNOX_AGE_KEY=\$(grep AGE-SECRET-KEY "$AGE_FILE") | |
| mise exec -- fnox set GITHUB_TOKEN --provider age | |
| mise exec -- fnox list | |
| The mirror serves on http://localhost:8080. Point the kiosk browser there. | |
| DONE | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| cat > "$DEST/systemd/magicmirror.service" << '__MM_PORTRAIT_INSTALLER_EOF__' | |
| # MagicMirror² — systemd *user* unit (serveronly mode). | |
| # | |
| # Runs as your user so it can read the fnox age key (~/.config/fnox) and the | |
| # mise-managed Node. `mise exec` puts node + fnox on PATH; `fnox exec` injects | |
| # GITHUB_TOKEN and the calendar URLs; then MagicMirror starts headless on :8080. | |
| # A separate kiosk browser (see README) points at http://localhost:8080. | |
| # | |
| # Assumes this repo lives at ~/mm and MagicMirror at ~/mm/MagicMirror. | |
| # Install: | |
| # mkdir -p ~/.config/systemd/user | |
| # ln -sf ~/mm/systemd/magicmirror.service ~/.config/systemd/user/magicmirror.service | |
| # loginctl enable-linger "$USER" # start at boot without login | |
| # systemctl --user daemon-reload | |
| # systemctl --user enable --now magicmirror | |
| [Unit] | |
| Description=MagicMirror2 portrait dashboard (serveronly) | |
| Documentation=https://docs.magicmirror.builders | |
| After=network-online.target | |
| Wants=network-online.target | |
| [Service] | |
| Type=simple | |
| WorkingDirectory=%h/mm | |
| # mise.toml is read from WorkingDirectory; fnox.toml too (root=true). | |
| ExecStart=%h/.local/bin/mise exec -- fnox exec -- node %h/mm/MagicMirror/serveronly | |
| Restart=always | |
| RestartSec=5 | |
| # Give the app a moment to shut down cleanly on restart/stop. | |
| TimeoutStopSec=15 | |
| # Keep logs readable in `journalctl --user -u magicmirror`. | |
| StandardOutput=journal | |
| StandardError=journal | |
| Environment=NODE_ENV=production | |
| # Light hardening that doesn't interfere with mise/fnox/node. | |
| NoNewPrivileges=true | |
| ProtectControlGroups=true | |
| ProtectKernelTunables=true | |
| [Install] | |
| WantedBy=default.target | |
| __MM_PORTRAIT_INSTALLER_EOF__ | |
| # --- make shell scripts executable ---------------------------------------- | |
| for rel in "${FILES[@]}"; do | |
| case "$rel" in *.sh) chmod +x "$DEST/$rel" ;; esac | |
| done | |
| # --- verification ---------------------------------------------------------- | |
| printf '\nVerifying checksums...\n' | |
| sha_cmd="" | |
| if command -v sha256sum >/dev/null 2>&1; then sha_cmd="sha256sum" | |
| elif command -v shasum >/dev/null 2>&1; then sha_cmd="shasum -a 256" | |
| fi | |
| declare -A SUMS=( | |
| [".gitignore"]="d09769cea999394e5c4f6dc40bc192c73bd4783a2ccac79f492a5efb9ebde707" | |
| ["README.md"]="5e65b483958200e246145ebc3377c6a7d4b26276c6fb737b32e53539a34600dc" | |
| ["config/config.js"]="c8b9edb2bd3e2afc2248e12db8a5545859314a7147bf5939ad53f86b7e720272" | |
| ["css/custom.css"]="045a07806d51abff9cee12706a520b4552a2036ed9528524b31366b632e3b8d4" | |
| ["fnox.toml"]="e42890a5ce91ffbb7a76ea2dfcae0c15fbc4263bc44bb034250a5b57bad7fb03" | |
| ["mise.toml"]="872776f4878850986eae95a24e941318433dd0dc4a0fe205e916b62539a803d3" | |
| ["modules/MMM-GitHubDashboard/MMM-GitHubDashboard.js"]="220f0c16a864532f51addd2adbd3be142de6e22acffc847264db8036d7f73c1c" | |
| ["modules/MMM-GitHubDashboard/node_helper.js"]="4264aafaff33ff83730d295e073966ad5fc103b05da3afce2f90b538ec64f3af" | |
| ["setup.sh"]="0d3831b6771cedd4fc06ce58a1acaf8024836a8cf7f47e6e94749f893abd2934" | |
| ["systemd/magicmirror.service"]="985f0b81ee97905dc961a02c81d768ce34e3a2eb72284b4cb1204d6254fbdbec" | |
| ) | |
| if [[ -z "$sha_cmd" ]]; then | |
| printf ' (no sha256 tool found; skipping verification)\n' | |
| else | |
| fail=0 | |
| for rel in "${FILES[@]}"; do | |
| got=$($sha_cmd "$DEST/$rel" | awk '{print $1}') | |
| if [[ "$got" == "${SUMS[$rel]}" ]]; then | |
| printf ' ok %s\n' "$rel" | |
| else | |
| printf ' FAIL %s\n' "$rel" >&2 | |
| fail=1 | |
| fi | |
| done | |
| if [[ $fail -ne 0 ]]; then | |
| printf '\nChecksum verification failed.\n' >&2; exit 1 | |
| fi | |
| fi | |
| printf '\nDone. %d files placed in %s\n' "${#FILES[@]}" "$DEST" | |
| printf '\nNext step - provision everything with one command:\n' | |
| printf ' cd %q && ./setup.sh\n' "$DEST" | |
| cat <<'NEXT' | |
| setup.sh installs Node+fnox via mise, generates the age key, clones | |
| MagicMirror, links your config, sets up the systemd service, and | |
| optionally the kiosk. It prompts for your GitHub username and secrets. | |
| Re-runnable any time; see ./setup.sh --help for flags. | |
| NEXT |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # MagicMirror portrait dashboard - single-file bootstrap. | |
| # | |
| # Put this one file on a gist, then on the Raspberry Pi: | |
| # | |
| # curl -fsSL <raw-gist-url> -o mm.sh && bash mm.sh | |
| # # ...or pipe it straight in (prompts still work via /dev/tty): | |
| # curl -fsSL <raw-gist-url> | bash | |
| # | |
| # It writes the whole project into ~/mm and then provisions everything: | |
| # Node 22 + fnox + age via mise, an age key, the MagicMirror checkout, the | |
| # systemd --user service, and (optionally) the Chromium kiosk + rotation. | |
| # | |
| # Requirements: a Raspberry Pi OS (Bookworm) Wayland/labwc session. mise is | |
| # used if present and installed automatically if not. Nothing here is secret; | |
| # your GitHub token is stored age-encrypted by fnox and never enters the repo. | |
| # | |
| # Re-runnable. Existing secrets (fnox.toml) and your edited config.js are | |
| # preserved; the rest is refreshed from this file. | |
| # | |
| # Flags (also work piped: curl ... | bash -s -- --username you --kiosk): | |
| # -y, --yes non-interactive; accept defaults, skip secret prompts | |
| # --username NAME GitHub username for the dashboard module | |
| # --dir PATH where to place the project (default: ~/mm) | |
| # --kiosk configure Chromium kiosk + screen rotation | |
| # --no-kiosk skip the kiosk step | |
| # --output NAME Wayland output for rotation (e.g. HDMI-A-1) | |
| # --mm-tag vX.Y.Z MagicMirror tag to clone (default: v2.37.0) | |
| # --overwrite also refresh config.js from the embedded template | |
| # -h, --help | |
| set -euo pipefail | |
| # -------------------------------------------------------------------------- | |
| # Defaults / flags | |
| # -------------------------------------------------------------------------- | |
| MM_TAG_DEFAULT="v2.37.0" | |
| # Exact Node pinned for the npm install so a stray system Node on PATH can't be | |
| # used. Must satisfy MagicMirror 2.37 (>=22.21.1 <23 || >=24) and match mise.toml. | |
| NODE_VERSION="22.22.2" | |
| MM_REPO_URL="https://github.com/MagicMirrorOrg/MagicMirror.git" | |
| ROTATE_TRANSFORM="90" # portrait: 90 = clockwise; 270 flips it | |
| ASSUME_YES=0 | |
| DO_KIOSK="ask" # ask | yes | no | |
| OVERWRITE=0 | |
| GH_USERNAME="${GH_USERNAME:-}" | |
| MM_TAG="$MM_TAG_DEFAULT" | |
| KIOSK_OUTPUT="${KIOSK_OUTPUT:-}" | |
| TARGET="${MM_DIR:-$HOME/mm}" | |
| # -------------------------------------------------------------------------- | |
| # Output helpers | |
| # -------------------------------------------------------------------------- | |
| if [[ -t 1 ]]; then | |
| C_B=$'\033[1;34m'; C_G=$'\033[1;32m'; C_Y=$'\033[1;33m'; C_R=$'\033[1;31m'; C_0=$'\033[0m' | |
| else | |
| C_B=""; C_G=""; C_Y=""; C_R=""; C_0="" | |
| fi | |
| step() { printf '\n%s==>%s %s\n' "$C_B" "$C_0" "$*"; } | |
| ok() { printf '%s ok%s %s\n' "$C_G" "$C_0" "$*"; } | |
| warn() { printf '%swarn%s %s\n' "$C_Y" "$C_0" "$*" >&2; } | |
| die() { printf '%serror%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } | |
| have() { command -v "$1" >/dev/null 2>&1; } | |
| HAVE_TTY=0; [[ -r /dev/tty ]] && HAVE_TTY=1 | |
| ask() { # ask "prompt" "default" -> echoes answer (reads /dev/tty so pipes work) | |
| local prompt="$1" def="${2:-}" ans | |
| if [[ $ASSUME_YES -eq 1 || $HAVE_TTY -eq 0 ]]; then echo "$def"; return; fi | |
| read -r -p "$prompt" ans </dev/tty || true | |
| echo "${ans:-$def}" | |
| } | |
| # ========================================================================== | |
| # Pure helpers (unit-tested via MM_LIB_ONLY) | |
| # ========================================================================== | |
| # File placement policy. POLICY: always | keep | secret | |
| # always -> overwrite (shipped code/theme/docs) | |
| # keep -> keep if present unless --overwrite (config.js: your edits) | |
| # secret -> never overwrite (fnox.toml: your encrypted secrets) | |
| _write() { | |
| local rel="$1" policy="$2" dest="$TARGET/$1" | |
| mkdir -p "$(dirname "$dest")" | |
| case "$policy" in | |
| secret) if [[ -e "$dest" ]]; then cat >/dev/null; printf ' keep %s (secrets preserved)\n' "$rel"; return; fi ;; | |
| keep) if [[ -e "$dest" && $OVERWRITE -eq 0 ]]; then cat >/dev/null; printf ' keep %s\n' "$rel"; return; fi ;; | |
| esac | |
| cat > "$dest" | |
| printf ' write %s\n' "$rel" | |
| } | |
| # Ensure the age provider in fnox.toml carries our recipient public key. | |
| inject_age_recipient() { | |
| local file="$1" pub="$2" | |
| [[ -f "$file" ]] || { echo "inject_age_recipient: no $file" >&2; return 1; } | |
| if grep -q "$pub" "$file"; then return 0; fi | |
| if grep -Eq '^[[:space:]]*age[[:space:]]*=.*recipients' "$file"; then | |
| echo "RECIPIENTS_EXIST"; return 2 | |
| fi | |
| sed -i.bak -E \ | |
| "s|^([[:space:]]*age[[:space:]]*=[[:space:]]*\{[[:space:]]*type[[:space:]]*=[[:space:]]*\"age\")[[:space:]]*\}|\1, recipients = [\"$pub\"] }|" \ | |
| "$file" | |
| rm -f "$file.bak" | |
| grep -q "$pub" "$file" | |
| } | |
| # Set the GitHub username in config.js. Replaces the placeholder, or any | |
| # previously-set value when a username is given explicitly. Idempotent. | |
| set_github_username() { | |
| local file="$1" user="$2" | |
| [[ -f "$file" ]] || { echo "set_github_username: no $file" >&2; return 1; } | |
| [[ "$user" =~ ^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$ ]] || { | |
| echo "invalid GitHub username: $user" >&2; return 1; } | |
| if grep -q "YOUR_GITHUB_USERNAME" "$file"; then | |
| sed -i.bak "s/YOUR_GITHUB_USERNAME/$user/g" "$file"; rm -f "$file.bak"; return 0 | |
| fi | |
| if grep -q "username: \"$user\"" "$file"; then echo "ALREADY_SET"; return 0; fi | |
| sed -i.bak -E "s/(username:[[:space:]]*\")[^\"]*(\")/\1$user\2/" "$file"; rm -f "$file.bak" | |
| } | |
| # Emit a systemd user unit with absolute paths resolved at install time. | |
| render_unit() { | |
| local mise_bin="$1" repo="$2" mm_home="$3" age_env="$4" | |
| cat <<UNIT | |
| # MagicMirror2 portrait dashboard (serveronly) - generated by the bootstrap. | |
| [Unit] | |
| Description=MagicMirror2 portrait dashboard (serveronly) | |
| Documentation=https://docs.magicmirror.builders | |
| After=network-online.target | |
| Wants=network-online.target | |
| [Service] | |
| Type=simple | |
| WorkingDirectory=$repo | |
| EnvironmentFile=-$age_env | |
| Environment=NODE_ENV=production | |
| ExecStart=$mise_bin exec -- fnox exec -- node $mm_home/serveronly | |
| Restart=always | |
| RestartSec=5 | |
| TimeoutStopSec=15 | |
| StandardOutput=journal | |
| StandardError=journal | |
| NoNewPrivileges=true | |
| ProtectControlGroups=true | |
| ProtectKernelTunables=true | |
| [Install] | |
| WantedBy=default.target | |
| UNIT | |
| } | |
| # Idempotent, marker-delimited labwc autostart block. Backs up any existing | |
| # file, removes a previous managed block, then appends a fresh one. Avoids | |
| # awk -v so the Chromium line-continuation backslash survives verbatim. | |
| install_autostart_block() { | |
| local file="$1" output="$2" transform="$3" url="$4" | |
| local begin="# >>> magicmirror-portrait >>>" | |
| local end="# <<< magicmirror-portrait <<<" | |
| local rotate_line | |
| if [[ -n "$output" ]]; then | |
| rotate_line="wlr-randr --output $output --transform $transform &" | |
| else | |
| rotate_line="# wlr-randr --output <YOUR-OUTPUT> --transform $transform & # find it with: wlr-randr" | |
| fi | |
| mkdir -p "$(dirname "$file")" | |
| if [[ -f "$file" ]]; then | |
| cp -p "$file" "$file.bak.$(date +%Y%m%d%H%M%S)" | |
| sed -i "/^${begin}\$/,/^${end}\$/d" "$file" | |
| fi | |
| { | |
| [[ -s "$file" ]] && printf '\n' | |
| printf '%s\n' "$begin" | |
| printf '%s\n' "# Screen rotation + Chromium kiosk for the MagicMirror dashboard." | |
| printf '%s\n' "$rotate_line" | |
| printf '%s\n' "chromium-browser --kiosk --noerrdialogs --disable-infobars --incognito \\" | |
| printf '%s\n' " --check-for-update-interval=31536000 \"$url\" &" | |
| printf '%s\n' "$end" | |
| } >> "$file" | |
| chmod +x "$file" 2>/dev/null || true | |
| } | |
| # Write every embedded project file into $TARGET according to policy. | |
| write_files() { | |
| step "Placing project files into $TARGET" | |
| _write ".gitignore" always <<'__MM_BOOTSTRAP_EOF__' | |
| # Vendored MagicMirror checkout (installed, not committed) | |
| /MagicMirror/ | |
| # Runtime cache written by the GitHub module | |
| modules/MMM-GitHubDashboard/cache.json | |
| # Never commit plaintext secrets or age keys | |
| .env | |
| *.key | |
| key.txt | |
| # Node | |
| node_modules/ | |
| npm-debug.log* | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "README.md" always <<'__MM_BOOTSTRAP_EOF__' | |
| # Portrait MagicMirror² Dashboard | |
| A low-maintenance MagicMirror² setup for a Raspberry Pi 5 driving a 27" 4K | |
| monitor in **portrait** orientation (2160×3840). Dark, Apple-inspired, single | |
| centered column, mostly built-in modules. Node is managed by **mise**, secrets | |
| by **fnox**, autostart by a **systemd user service**. | |
| This repo is the source of truth. MagicMirror itself is cloned alongside it and | |
| the config/theme/module are symlinked in, so upgrading the app never touches | |
| your setup. | |
| ## Layout (top → bottom) | |
| 1. Large thin clock + date (hero) | |
| 2. Current weather with today's high/low | |
| 3. Compact 7-day forecast | |
| 4. Calendar — next 7 days across Apple + Google, including birthdays | |
| 5. GitHub summary — unread notifications, open PRs involving you, failing CI, and | |
| up to three actionable items | |
| 6. Rotating RSS newsfeed (bottom ticker) | |
| Weather uses **Open-Meteo**, which needs no API key — one less secret and one | |
| less thing to expire. Swap in OpenWeatherMap later if you prefer. | |
| ## Prerequisites | |
| - Raspberry Pi OS (Bookworm), Wayland/labwc session | |
| - [`mise`](https://mise.jdx.dev) installed and on `PATH` | |
| - `git` | |
| The repo is assumed to live at `~/mm`. Adjust `MM_HOME` in `mise.toml` and the | |
| paths in `systemd/magicmirror.service` if you put it elsewhere. | |
| ## Install | |
| ```bash | |
| # 1. Toolchain (Node 22 + fnox) | |
| cd ~/mm | |
| mise trust && mise install | |
| # 2. MagicMirror itself (vendored, git-ignored) | |
| git clone https://github.com/MagicMirrorOrg/MagicMirror.git ~/mm/MagicMirror | |
| cd ~/mm/MagicMirror && npm run install-mm && cd ~/mm | |
| # 3. Symlink this repo's config, theme and module into the checkout | |
| mise run link | |
| ``` | |
| ## Secrets (fnox) | |
| ```bash | |
| fnox init # generates the local age key (~/.config/fnox) | |
| fnox set GITHUB_TOKEN # fine-grained PAT, read-only (see below) | |
| fnox set ICAL_APPLE # iCloud "Public Calendar" URL | |
| fnox set ICAL_APPLE_BDAY # iCloud Birthdays URL (optional) | |
| fnox set ICAL_GOOGLE # Google "Secret address in iCal format" | |
| fnox set ICAL_GOOGLE_BDAY # Google Birthdays URL (optional) | |
| fnox list # confirm names are set (values stay hidden) | |
| ``` | |
| GitHub PAT scopes (read-only): **Notifications: Read**, **Pull requests: Read**, | |
| **Checks: Read**, **Metadata: Read**. Then set your handle in `config/config.js` | |
| (`username: "YOUR_GITHUB_USERNAME"`). | |
| The GitHub token is read **only** by the module's `node_helper` on the server | |
| and is never sent to the browser or written to logs. Calendar ICS URLs are | |
| consumed by the built-in calendar module and therefore do reach the local | |
| browser — use read-only share links and don't reuse anything sensitive. | |
| ## Validate | |
| ```bash | |
| mise run validate # runs MagicMirror's own js/check_config.js with secrets loaded | |
| ``` | |
| Expected: "doesn't contain syntax errors" and "modules structure ... doesn't | |
| contain errors". Validation covers the full config; the GitHub module is | |
| additive — if its folder or token is missing, MagicMirror logs a warning and the | |
| rest of the dashboard runs unaffected. | |
| ## Start (systemd user service) | |
| ```bash | |
| mkdir -p ~/.config/systemd/user | |
| ln -sf ~/mm/systemd/magicmirror.service ~/.config/systemd/user/magicmirror.service | |
| loginctl enable-linger "$USER" # start at boot without logging in | |
| systemctl --user daemon-reload | |
| systemctl --user enable --now magicmirror | |
| ``` | |
| This runs MagicMirror **serveronly** on `http://localhost:8080`. The display is a | |
| separate kiosk browser (below), which keeps the always-on service simple and | |
| crash-resilient. | |
| ### Display + portrait rotation | |
| The panel is physically rotated, so rotate at the compositor, not in CSS. Add to | |
| your labwc autostart (`~/.config/labwc/autostart`), using your real output name | |
| from `wlr-randr`: | |
| ```bash | |
| wlr-randr --output HDMI-A-1 --transform 90 & | |
| chromium-browser --kiosk --app=http://localhost:8080 \ | |
| --noerrdialogs --disable-infobars --incognito & | |
| ``` | |
| Use `--transform 270` if it rotates the wrong way. `kanshi` can make the | |
| rotation persistent across reboots/hotplug. | |
| ## Operate | |
| ```bash | |
| # Logs (follow) | |
| mise run logs | |
| # or: journalctl --user -u magicmirror -f -o cat | |
| # Restart after a config or theme change | |
| systemctl --user restart magicmirror | |
| # Status | |
| systemctl --user status magicmirror | |
| ``` | |
| ## Rollback | |
| Config, theme and the module are version-controlled, so rollback is a git | |
| operation plus a restart: | |
| ```bash | |
| # Revert the last change to a single file | |
| git checkout -- config/config.js && systemctl --user restart magicmirror | |
| # Or roll the whole repo back to the last known-good commit | |
| git log --oneline | |
| git revert <bad_commit> # keeps history, or: | |
| git reset --hard <good_commit> # discards local changes | |
| systemctl --user restart magicmirror | |
| ``` | |
| To roll back MagicMirror itself: | |
| ```bash | |
| cd ~/mm/MagicMirror && git checkout <previous_tag> && npm run install-mm | |
| systemctl --user restart magicmirror | |
| ``` | |
| ## Repository layout | |
| ``` | |
| mise.toml Node 22 + fnox; link/validate/start/logs tasks | |
| fnox.toml Secret references (no plaintext); safe to commit | |
| config/config.js Modules + portrait layout; reads calendar URLs from env | |
| css/custom.css Dark Apple-inspired theme (incl. GitHub module styles) | |
| modules/MMM-GitHubDashboard/ | |
| MMM-GitHubDashboard.js Browser side — renders counts/items/status; no token | |
| node_helper.js Server side — token, polling, timeout, cache, stale | |
| systemd/magicmirror.service serveronly via mise + fnox, auto-restart | |
| ``` | |
| ## GitHub module behavior | |
| - Polls no more often than every 5 minutes (floored on both sides). | |
| - Aborts each request after `requestTimeout` (default 8s). | |
| - Caches the last good result in memory and on disk | |
| (`modules/MMM-GitHubDashboard/cache.json`), so a restart paints immediately. | |
| - On any failure it keeps showing the cached data with a compact amber "Stale" | |
| status; with no cache it shows a single red error line. | |
| - Renders three counts plus at most three actionable items, prioritized: | |
| review requests → failing CI → mentions. | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "config/config.js" keep <<'__MM_BOOTSTRAP_EOF__' | |
| /* MagicMirror² configuration — portrait kitchen dashboard (2160×3840). | |
| * | |
| * Layout is a single centered column, top to bottom: | |
| * clock → current weather → 7-day forecast → calendar → GitHub → newsfeed. | |
| * | |
| * Secrets come from the environment (injected by `fnox exec --`); this file | |
| * never contains a token or a raw calendar URL. Weather uses Open-Meteo, which | |
| * needs no API key, so the only env-supplied values here are calendar ICS URLs. | |
| */ | |
| // --- Location ----------------------------------------------------------------- | |
| const LAT = 33.3062; // Chandler, AZ | |
| const LON = -111.8413; | |
| const TZ = "America/Phoenix"; | |
| // --- Calendars ---------------------------------------------------------------- | |
| // Build the calendar list from the environment, skipping any that are unset so | |
| // an empty variable never becomes a broken fetcher. Colors (not the built-in | |
| // symbol) are what visually separate the sources, matching the flat dark theme. | |
| const calendarSources = [ | |
| { env: "ICAL_APPLE", name: "Apple", symbol: "calendar", color: "#0A84FF" }, | |
| { env: "ICAL_APPLE_BDAY", name: "Birthdays", symbol: "gift", color: "#FF9F0A" }, | |
| { env: "ICAL_GOOGLE", name: "Google", symbol: "calendar", color: "#30D158" }, | |
| { env: "ICAL_GOOGLE_BDAY", name: "Birthdays", symbol: "gift", color: "#FF9F0A" }, | |
| ]; | |
| const calendars = calendarSources | |
| .filter((c) => (process.env[c.env] || "").trim().length > 0) | |
| .map((c) => ({ | |
| url: process.env[c.env].trim(), | |
| name: c.name, | |
| symbol: c.symbol, | |
| color: c.color, | |
| })); | |
| let config = { | |
| address: "127.0.0.1", // serveronly + local kiosk browser only | |
| port: 8080, | |
| ipWhitelist: ["127.0.0.1", "::1"], | |
| ipAllowlist: ["127.0.0.1", "::1"], | |
| language: "en", | |
| locale: "en-US", | |
| timeFormat: 12, | |
| units: "imperial", | |
| modules: [ | |
| // 1) Hero: large thin clock + date ------------------------------------- | |
| { | |
| module: "clock", | |
| position: "top_bar", | |
| config: { | |
| timezone: TZ, | |
| displaySeconds: false, | |
| timeFormat: 12, | |
| showPeriod: true, | |
| showPeriodUpper: true, | |
| clockBold: false, | |
| dateFormat: "dddd, MMMM D", | |
| }, | |
| }, | |
| // 2) Current conditions + today's high/low ----------------------------- | |
| { | |
| module: "weather", | |
| position: "top_center", | |
| config: { | |
| weatherProvider: "openmeteo", | |
| type: "current", | |
| lat: LAT, | |
| lon: LON, | |
| tempUnits: "imperial", | |
| windUnits: "imperial", | |
| degreeLabel: true, | |
| showHumidity: "none", | |
| showWindDirection: false, | |
| showFeelsLike: false, | |
| showSun: false, | |
| }, | |
| }, | |
| // 6) Compact 7-day forecast (placed under current, before calendar) ---- | |
| { | |
| module: "weather", | |
| position: "top_center", | |
| header: "7-Day Forecast", | |
| config: { | |
| weatherProvider: "openmeteo", | |
| type: "daily", | |
| lat: LAT, | |
| lon: LON, | |
| tempUnits: "imperial", | |
| maxNumberOfDays: 7, | |
| fade: false, | |
| colored: true, | |
| tableClass: "small", | |
| }, | |
| }, | |
| // 3) Calendar: next 7 days incl. birthdays ----------------------------- | |
| { | |
| module: "calendar", | |
| position: "top_center", | |
| header: "Next 7 Days", | |
| config: { | |
| maximumNumberOfDays: 7, | |
| maximumEntries: 30, | |
| fetchInterval: 15 * 60 * 1000, // 15 min | |
| timeFormat: "absolute", | |
| getRelative: 0, | |
| urgency: 0, | |
| fade: false, | |
| showLocation: false, | |
| wrapEvents: true, | |
| maxTitleLength: 40, | |
| calendars: calendars, | |
| }, | |
| }, | |
| // 4) GitHub dashboard (custom module; see modules/MMM-GitHubDashboard) -- | |
| { | |
| module: "MMM-GitHubDashboard", | |
| position: "top_center", | |
| header: "GitHub", | |
| config: { | |
| // NOTE: no token here. The node_helper reads GITHUB_TOKEN from the | |
| // environment; the browser never receives it. | |
| username: "YOUR_GITHUB_USERNAME", | |
| updateInterval: 5 * 60 * 1000, // 5 min (also floored server-side) | |
| requestTimeout: 8000, | |
| maxActionable: 3, | |
| }, | |
| }, | |
| // 5) Rotating newsfeed -------------------------------------------------- | |
| { | |
| module: "newsfeed", | |
| position: "bottom_bar", | |
| config: { | |
| feeds: [ | |
| { title: "AP", url: "https://feedx.net/rss/ap.xml" }, | |
| { title: "NPR", url: "https://feeds.npr.org/1001/rss.xml" }, | |
| { title: "BBC", url: "https://feeds.bbci.co.uk/news/world/rss.xml" }, | |
| ], | |
| showSourceTitle: true, | |
| showPublishDate: true, | |
| showDescription: false, | |
| showAsList: false, | |
| wrapTitle: true, | |
| updateInterval: 12 * 1000, // rotate headline every 12s | |
| reloadInterval: 10 * 60 * 1000, // refetch every 10 min | |
| maxNewsItems: 20, | |
| ignoreOldItems: true, | |
| ignoreOlderThan: 24 * 60 * 60 * 1000, | |
| broadcastNewsFeeds: false, | |
| }, | |
| }, | |
| ], | |
| }; | |
| /*************** DO NOT EDIT BELOW THIS LINE ***************/ | |
| if (typeof module !== "undefined") { | |
| module.exports = config; | |
| } | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "css/custom.css" always <<'__MM_BOOTSTRAP_EOF__' | |
| /* custom.css — dark, Apple-inspired theme for a 2160×3840 portrait panel. | |
| * | |
| * Design thesis: the clock is the hero — oversized and ultra-thin. Everything | |
| * else is a quiet, hairline-bordered "material" card in a single centered | |
| * column. One accent (system blue) carries "now"; birthdays borrow the warm | |
| * orange defined in config.js. Sizes are scaled up for a 4K panel read from | |
| * across a room. | |
| */ | |
| :root { | |
| --bg: #000000; | |
| --ink: rgba(255, 255, 255, 0.92); /* primary text */ | |
| --ink-2: rgba(255, 255, 255, 0.55); /* secondary text */ | |
| --ink-3: rgba(255, 255, 255, 0.30); /* tertiary / labels */ | |
| --hairline: rgba(255, 255, 255, 0.10); | |
| --card: rgba(255, 255, 255, 0.04); | |
| --accent: #0a84ff; /* Apple system blue (dark) */ | |
| --warn: #ff9f0a; /* stale / birthdays */ | |
| --bad: #ff453a; /* errors / failing CI */ | |
| --good: #30d158; /* healthy CI */ | |
| --font: -apple-system, "SF Pro Display", "SF Pro Text", "Helvetica Neue", | |
| "Inter", "Roboto", system-ui, sans-serif; | |
| --col: 1500px; /* content column width */ | |
| --gap: 90px; /* vertical rhythm between cards */ | |
| } | |
| /* Base ---------------------------------------------------------------------- */ | |
| html { | |
| font-size: 100%; | |
| } | |
| body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font-family: var(--font); | |
| font-weight: 300; | |
| line-height: 1.35; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| /* Kill MagicMirror's default vignette/gradients for a flat OLED black. */ | |
| .region.fullscreen, | |
| body::before, | |
| body::after { | |
| background: none !important; | |
| } | |
| /* Center the whole column and give the bars breathing room. */ | |
| .region.top.bar { | |
| width: 100%; | |
| top: 130px; | |
| left: 0; | |
| right: 0; | |
| text-align: center; | |
| } | |
| .region.top.center { | |
| width: var(--col); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| top: 620px; /* clears the hero clock above */ | |
| text-align: center; | |
| } | |
| .region.bottom.bar { | |
| width: 100%; | |
| bottom: 80px; | |
| } | |
| /* Generic type helpers MagicMirror sprinkles around ------------------------- */ | |
| .normal, | |
| .dimmed, | |
| .bright { | |
| color: var(--ink); | |
| } | |
| .dimmed { | |
| color: var(--ink-3); | |
| } | |
| header, | |
| .module-header { | |
| color: var(--ink-3); | |
| font-size: 26px; | |
| font-weight: 500; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| border: none; | |
| padding: 0 0 18px 0; | |
| margin: 0; | |
| } | |
| /* Card treatment for the stacked data modules. The clock and newsfeed opt | |
| out (they are the hero and the ticker, not cards). */ | |
| .region.top.center > .module { | |
| background: var(--card); | |
| border: 1px solid var(--hairline); | |
| border-radius: 28px; | |
| padding: 46px 54px; | |
| margin: 0 auto var(--gap) auto; | |
| max-width: var(--col); | |
| box-sizing: border-box; | |
| backdrop-filter: blur(24px); | |
| -webkit-backdrop-filter: blur(24px); | |
| } | |
| /* 1) Clock — the hero ------------------------------------------------------- */ | |
| .module.clock { | |
| background: none; | |
| border: none; | |
| padding: 0; | |
| } | |
| .module.clock .clock-grid, | |
| .module.clock .clockCircle { | |
| margin: 0 auto; | |
| } | |
| .clock .time { | |
| font-size: 340px; | |
| font-weight: 200; | |
| letter-spacing: -0.02em; | |
| line-height: 0.95; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .clock .time sup { | |
| font-size: 90px; | |
| font-weight: 300; | |
| color: var(--ink-2); | |
| vertical-align: 34px; | |
| } | |
| .clock .date { | |
| font-size: 62px; | |
| font-weight: 300; | |
| color: var(--ink-2); | |
| margin-top: 6px; | |
| } | |
| /* 2) + 6) Weather ----------------------------------------------------------- */ | |
| .module.weather { | |
| font-size: 40px; | |
| } | |
| .module.weather .weathericon { | |
| font-size: 64px; | |
| } | |
| .module.weather .current .large { | |
| font-size: 96px; | |
| font-weight: 200; | |
| } | |
| /* Forecast rows: airy, tabular, muted day labels. */ | |
| .module.weather table.small { | |
| font-size: 40px; | |
| width: 100%; | |
| border-spacing: 0 14px; | |
| border-collapse: separate; | |
| } | |
| .module.weather table.small .day { | |
| color: var(--ink-2); | |
| text-align: left; | |
| font-weight: 400; | |
| } | |
| .module.weather table.small .align-right { | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .module.weather .max-temp { | |
| color: var(--ink); | |
| } | |
| .module.weather .min-temp { | |
| color: var(--ink-3); | |
| } | |
| /* 3) Calendar --------------------------------------------------------------- */ | |
| .module.calendar { | |
| font-size: 40px; | |
| } | |
| .calendar table { | |
| width: 100%; | |
| border-spacing: 0 20px; | |
| border-collapse: separate; | |
| } | |
| .calendar .symbol { | |
| color: var(--ink-2); | |
| padding-right: 24px; | |
| font-size: 34px; | |
| } | |
| .calendar .title { | |
| color: var(--ink); | |
| font-weight: 400; | |
| } | |
| .calendar .time, | |
| .calendar .date { | |
| color: var(--ink-2); | |
| font-variant-numeric: tabular-nums; | |
| padding-left: 24px; | |
| white-space: nowrap; | |
| } | |
| .calendar .today .title, | |
| .calendar .today .time { | |
| color: var(--accent); | |
| font-weight: 500; | |
| } | |
| /* 5) Newsfeed — bottom ticker ---------------------------------------------- */ | |
| .module.newsfeed { | |
| background: none; | |
| border: none; | |
| border-top: 1px solid var(--hairline); | |
| padding: 40px 120px 0 120px; | |
| text-align: center; | |
| } | |
| .newsfeed .newsfeed-source { | |
| color: var(--ink-3); | |
| font-size: 28px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .newsfeed .newsfeed-title { | |
| color: var(--ink); | |
| font-size: 44px; | |
| font-weight: 300; | |
| } | |
| /* 4) GitHub dashboard — styles live here so the module ships CSS-free ------- */ | |
| .MMM-GitHubDashboard { | |
| font-size: 40px; | |
| } | |
| .ghd-counts { | |
| display: flex; | |
| justify-content: space-around; | |
| gap: 40px; | |
| margin-bottom: 36px; | |
| } | |
| .ghd-counts .ghd-metric { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| min-width: 200px; | |
| } | |
| .ghd-metric .ghd-value { | |
| font-size: 92px; | |
| font-weight: 200; | |
| line-height: 1; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .ghd-metric .ghd-label { | |
| margin-top: 12px; | |
| color: var(--ink-3); | |
| font-size: 26px; | |
| letter-spacing: 0.10em; | |
| text-transform: uppercase; | |
| } | |
| .ghd-metric.ghd-bad .ghd-value { | |
| color: var(--bad); | |
| } | |
| .ghd-items { | |
| list-style: none; | |
| margin: 0; | |
| padding: 24px 0 0 0; | |
| border-top: 1px solid var(--hairline); | |
| text-align: left; | |
| } | |
| .ghd-items li { | |
| display: flex; | |
| align-items: baseline; | |
| gap: 20px; | |
| padding: 16px 0; | |
| } | |
| .ghd-items .ghd-kind { | |
| flex: 0 0 auto; | |
| font-size: 24px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--ink-3); | |
| min-width: 200px; | |
| } | |
| .ghd-items .ghd-kind.ghd-review { | |
| color: var(--accent); | |
| } | |
| .ghd-items .ghd-kind.ghd-ci { | |
| color: var(--bad); | |
| } | |
| .ghd-items .ghd-text { | |
| flex: 1 1 auto; | |
| color: var(--ink); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .ghd-items .ghd-repo { | |
| color: var(--ink-3); | |
| font-size: 30px; | |
| } | |
| /* Compact, always-visible status line. Green dot = fresh, amber = stale. */ | |
| .ghd-status { | |
| margin-top: 28px; | |
| font-size: 26px; | |
| color: var(--ink-3); | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| } | |
| .ghd-status::before { | |
| content: ""; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--good); | |
| flex: 0 0 auto; | |
| } | |
| .ghd-status.ghd-stale { | |
| color: var(--warn); | |
| } | |
| .ghd-status.ghd-stale::before { | |
| background: var(--warn); | |
| } | |
| .ghd-status.ghd-error { | |
| color: var(--bad); | |
| } | |
| .ghd-status.ghd-error::before { | |
| background: var(--bad); | |
| } | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "fnox.toml" secret <<'__MM_BOOTSTRAP_EOF__' | |
| # fnox.toml — secret *references* only. Safe to commit. | |
| # | |
| # fnox loads these into the environment when you run `fnox exec -- <cmd>`. | |
| # The systemd unit and the mise tasks both go through `fnox exec`, so the | |
| # MagicMirror process sees them as plain environment variables. | |
| # | |
| # Nothing secret lives in this file. With the `age` provider below, encrypted | |
| # values would be stored inline (still safe to commit). Swap the provider block | |
| # for 1Password / AWS / Vault if you prefer a remote backend — the [secrets] | |
| # section stays the same. | |
| # | |
| # One-time setup: | |
| # mise install # gets node + fnox | |
| # fnox init # writes an age keypair to ~/.config/fnox | |
| # fnox set GITHUB_TOKEN # paste a fine-grained PAT (prompt is hidden) | |
| # fnox set ICAL_APPLE # iCloud "Public Calendar" webcal/https URL | |
| # fnox set ICAL_APPLE_BDAY # iCloud Birthdays calendar URL (optional) | |
| # fnox set ICAL_GOOGLE # Google "Secret address in iCal format" | |
| # fnox set ICAL_GOOGLE_BDAY # Google Birthdays calendar URL (optional) | |
| # | |
| # Verify without revealing values: fnox list | |
| # Verify the runtime environment: fnox exec -- node -e "console.log(!!process.env.GITHUB_TOKEN)" | |
| root = true | |
| [providers] | |
| # age = local encryption. `fnox init` generates the recipient/key for you and | |
| # rewrites this line. Committed values are ciphertext. | |
| age = { type = "age" } | |
| [secrets] | |
| # --- GitHub (server-side only, never sent to the browser) --------------------- | |
| # Fine-grained PAT. Read-only is enough: | |
| # Notifications: Read | Pull requests: Read | Checks: Read | |
| # Contents/Metadata: Read (metadata is mandatory) | |
| GITHUB_TOKEN = { provider = "age", value = "", description = "Fine-grained GitHub PAT (read-only)", if_missing = "warn" } | |
| # --- Calendars (ICS URLs; read-only share links) ----------------------------- | |
| # These are consumed by the built-in calendar module and are therefore visible | |
| # to the local browser. Treat them as low-value read-only links; do not reuse a | |
| # link that exposes anything you would not put on the kitchen wall. | |
| ICAL_APPLE = { provider = "age", value = "", description = "iCloud personal calendar ICS", if_missing = "warn" } | |
| ICAL_APPLE_BDAY = { provider = "age", value = "", description = "iCloud birthdays calendar ICS", if_missing = "warn" } | |
| ICAL_GOOGLE = { provider = "age", value = "", description = "Google personal calendar ICS", if_missing = "warn" } | |
| ICAL_GOOGLE_BDAY = { provider = "age", value = "", description = "Google birthdays calendar ICS", if_missing = "warn" } | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "mise.toml" always <<'__MM_BOOTSTRAP_EOF__' | |
| # mise.toml — toolchain for the MagicMirror portrait dashboard. | |
| # | |
| # mise owns Node (MagicMirror v2.37 needs Node >=22.22.2 <23 || >=24) and the | |
| # fnox secrets CLI. Nothing here contains a secret; fnox injects those at run | |
| # time (see fnox.toml and systemd/magicmirror.service). | |
| [tools] | |
| node = "22.22.2" # exact; MagicMirror 2.37 needs >=22.21.1 <23 || >=24 | |
| fnox = "latest" # secrets front-end, injected via `fnox exec --` | |
| age = "latest" # provides age-keygen for the fnox age provider | |
| [env] | |
| # Where MagicMirror itself is checked out. Adjust once, everything else follows. | |
| MM_HOME = "{{ config_root }}/MagicMirror" | |
| # Point the browser/kiosk here. serveronly binds to this. | |
| MM_PORT = "8080" | |
| # --- Tasks -------------------------------------------------------------------- | |
| # Run these from the repo root. Each secret-consuming task is wrapped in | |
| # `fnox exec --` so GITHUB_TOKEN and the calendar URLs arrive as env vars and | |
| # never touch disk or git. | |
| [tasks.link] | |
| description = "Symlink this repo's config + modules into the MagicMirror checkout" | |
| run = """ | |
| set -euo pipefail | |
| ln -sf "{{ config_root }}/config/config.js" "$MM_HOME/config/config.js" | |
| ln -sf "{{ config_root }}/css/custom.css" "$MM_HOME/css/custom.css" | |
| ln -sfn "{{ config_root }}/modules/MMM-GitHubDashboard" "$MM_HOME/modules/MMM-GitHubDashboard" | |
| echo "Linked config.js, custom.css and MMM-GitHubDashboard into $MM_HOME" | |
| """ | |
| [tasks.validate] | |
| description = "Run MagicMirror's own config checker against config/config.js" | |
| dir = "{{ config_root }}" | |
| run = "fnox exec -- node \"$MM_HOME/js/check_config.js\"" | |
| [tasks.start] | |
| description = "Start MagicMirror in server-only mode with secrets injected" | |
| dir = "{{ config_root }}" | |
| run = "fnox exec -- node \"$MM_HOME/serveronly\"" | |
| [tasks.logs] | |
| description = "Tail the systemd journal for the mirror service" | |
| run = "journalctl --user -u magicmirror -f -o cat" | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "modules/MMM-GitHubDashboard/MMM-GitHubDashboard.js" always <<'__MM_BOOTSTRAP_EOF__' | |
| /* global Module, Log */ | |
| /** | |
| * MMM-GitHubDashboard (browser side) | |
| * | |
| * This half never sees the token and never talks to GitHub. It sends its | |
| * (non-secret) config to the node_helper and renders whatever view-model the | |
| * helper sends back: three counts, up to `maxActionable` items, and a compact | |
| * always-visible status line (fresh / stale / error). | |
| * | |
| * All markup is built with DOM APIs + textContent so untrusted PR/notification | |
| * titles can never inject HTML. | |
| */ | |
| Module.register("MMM-GitHubDashboard", { | |
| defaults: { | |
| username: "", // required; set in config.js | |
| updateInterval: 5 * 60 * 1000, // floored to 5 min server-side too | |
| requestTimeout: 8000, | |
| maxActionable: 3, | |
| }, | |
| start() { | |
| this.viewModel = null; | |
| this.loaded = false; | |
| // Never send a token — the helper reads it from process.env itself. | |
| this.sendSocketNotification("GHD_SET_CONFIG", { | |
| identifier: this.identifier, | |
| username: this.config.username, | |
| // Enforce the 5-minute floor here as well as in the helper. | |
| updateInterval: Math.max(this.config.updateInterval, 5 * 60 * 1000), | |
| requestTimeout: this.config.requestTimeout, | |
| maxActionable: Math.min(Math.max(this.config.maxActionable, 0), 3), | |
| }); | |
| }, | |
| socketNotificationReceived(notification, payload) { | |
| if (!payload || payload.identifier !== this.identifier) return; | |
| if (notification === "GHD_DATA") { | |
| this.viewModel = payload; | |
| this.loaded = true; | |
| this.updateDom(300); | |
| } | |
| }, | |
| // ---- rendering ---------------------------------------------------------- | |
| getDom() { | |
| const root = document.createElement("div"); | |
| root.className = "MMM-GitHubDashboard"; | |
| if (!this.loaded) { | |
| const loading = document.createElement("div"); | |
| loading.className = "ghd-status"; | |
| loading.textContent = "Loading…"; | |
| root.appendChild(loading); | |
| return root; | |
| } | |
| const vm = this.viewModel; | |
| // Counts row (hidden only if we have literally nothing cached yet). | |
| if (vm.counts) { | |
| root.appendChild( | |
| this._counts([ | |
| ["notifications", vm.counts.notifications, "Unread", false], | |
| ["prs", vm.counts.openPRs, "Open PRs", false], | |
| ["ci", vm.counts.failingCI, "Failing CI", vm.counts.failingCI > 0], | |
| ]), | |
| ); | |
| } | |
| // Up to N actionable items. | |
| if (vm.items && vm.items.length) { | |
| root.appendChild(this._items(vm.items)); | |
| } | |
| root.appendChild(this._status(vm)); | |
| return root; | |
| }, | |
| _counts(metrics) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "ghd-counts"; | |
| for (const [key, value, label, bad] of metrics) { | |
| const cell = document.createElement("div"); | |
| cell.className = "ghd-metric" + (bad ? " ghd-bad" : ""); | |
| cell.dataset.key = key; | |
| const v = document.createElement("span"); | |
| v.className = "ghd-value"; | |
| v.textContent = value == null ? "—" : String(value); | |
| const l = document.createElement("span"); | |
| l.className = "ghd-label"; | |
| l.textContent = label; | |
| cell.appendChild(v); | |
| cell.appendChild(l); | |
| wrap.appendChild(cell); | |
| } | |
| return wrap; | |
| }, | |
| _items(items) { | |
| const ul = document.createElement("ul"); | |
| ul.className = "ghd-items"; | |
| for (const it of items) { | |
| const li = document.createElement("li"); | |
| const kind = document.createElement("span"); | |
| kind.className = | |
| "ghd-kind" + (it.kind === "review" ? " ghd-review" : it.kind === "ci" ? " ghd-ci" : ""); | |
| kind.textContent = it.kindLabel; | |
| const text = document.createElement("span"); | |
| text.className = "ghd-text"; | |
| text.textContent = it.title; | |
| const repo = document.createElement("span"); | |
| repo.className = "ghd-repo"; | |
| repo.textContent = it.repo || ""; | |
| li.appendChild(kind); | |
| li.appendChild(text); | |
| li.appendChild(repo); | |
| ul.appendChild(li); | |
| } | |
| return ul; | |
| }, | |
| _status(vm) { | |
| const s = document.createElement("div"); | |
| s.className = "ghd-status"; | |
| if (vm.error && !vm.counts) { | |
| // No data at all to show. | |
| s.classList.add("ghd-error"); | |
| s.textContent = vm.error; | |
| return s; | |
| } | |
| if (vm.stale || vm.error) { | |
| s.classList.add(vm.error ? "ghd-error" : "ghd-stale"); | |
| const ago = this._ago(vm.lastSuccess); | |
| s.textContent = (vm.error ? "GitHub unavailable" : "Stale") + (ago ? ` · updated ${ago}` : ""); | |
| return s; | |
| } | |
| const ago = this._ago(vm.lastSuccess); | |
| s.textContent = ago ? `Updated ${ago}` : "Updated just now"; | |
| return s; | |
| }, | |
| _ago(ts) { | |
| if (!ts) return ""; | |
| const mins = Math.max(0, Math.round((Date.now() - ts) / 60000)); | |
| if (mins < 1) return "just now"; | |
| if (mins < 60) return `${mins}m ago`; | |
| const hrs = Math.round(mins / 60); | |
| return `${hrs}h ago`; | |
| }, | |
| }); | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "modules/MMM-GitHubDashboard/node_helper.js" always <<'__MM_BOOTSTRAP_EOF__' | |
| /* MMM-GitHubDashboard — node_helper (server side) | |
| * | |
| * The token lives ONLY here. It is read from process.env.GITHUB_TOKEN, used to | |
| * build request headers, and never sent to the browser and never logged. The | |
| * browser receives a small view-model (counts + up to N items + status). | |
| * | |
| * Behaviour required of this helper: | |
| * - poll no more often than every 5 minutes (floored below) | |
| * - abort each request after a timeout | |
| * - cache the last successful result (in memory + on disk) | |
| * - keep showing stale data when GitHub is unavailable | |
| * - never log the token or authenticated request headers | |
| */ | |
| const NodeHelper = require("node_helper"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const API = "https://api.github.com"; | |
| const MIN_INTERVAL = 5 * 60 * 1000; // hard floor: 5 minutes | |
| const CACHE_FILE = path.join(__dirname, "cache.json"); | |
| module.exports = NodeHelper.create({ | |
| start() { | |
| this.configs = {}; // identifier -> config (no token) | |
| this.timers = {}; // identifier -> interval handle | |
| this.cache = this._loadCache(); // identifier -> last good view-model | |
| console.log("[MMM-GitHubDashboard] helper started"); | |
| }, | |
| socketNotificationReceived(notification, payload) { | |
| if (notification !== "GHD_SET_CONFIG" || !payload || !payload.identifier) return; | |
| const id = payload.identifier; | |
| this.configs[id] = { | |
| username: (payload.username || "").trim(), | |
| interval: Math.max(Number(payload.updateInterval) || MIN_INTERVAL, MIN_INTERVAL), | |
| timeout: Math.max(Number(payload.requestTimeout) || 8000, 1000), | |
| maxActionable: Math.min(Math.max(Number(payload.maxActionable) || 3, 0), 3), | |
| }; | |
| // If we already have cached data, paint it immediately (great after a | |
| // restart or when GitHub is down at boot). | |
| if (this.cache[id]) { | |
| this._send(id, { ...this.cache[id], stale: true }); | |
| } | |
| // (Re)start the poll loop for this instance. | |
| if (this.timers[id]) clearInterval(this.timers[id]); | |
| this._poll(id); | |
| this.timers[id] = setInterval(() => this._poll(id), this.configs[id].interval); | |
| }, | |
| // ---- polling ------------------------------------------------------------ | |
| async _poll(id) { | |
| const cfg = this.configs[id]; | |
| if (!cfg) return; | |
| const token = process.env.GITHUB_TOKEN; | |
| if (!token) { | |
| // Deliberately do not fabricate data; surface a clear, compact state. | |
| return this._fail(id, "GITHUB_TOKEN not set"); | |
| } | |
| if (!cfg.username) { | |
| return this._fail(id, "username not configured"); | |
| } | |
| // Headers are built locally and never logged. | |
| const headers = { | |
| Authorization: `Bearer ${token}`, | |
| Accept: "application/vnd.github+json", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| "User-Agent": "MMM-GitHubDashboard", | |
| }; | |
| const user = cfg.username; | |
| const q = (s) => `${API}/search/issues?q=${encodeURIComponent(s)}&per_page=5`; | |
| try { | |
| const [notifications, openPRs, reviews, failing] = await Promise.all([ | |
| this._getJson(`${API}/notifications?all=false&per_page=50`, headers, cfg.timeout), | |
| this._getJson(q(`is:open is:pr involves:${user}`), headers, cfg.timeout), | |
| this._getJson(q(`is:open is:pr review-requested:${user}`), headers, cfg.timeout), | |
| this._getJson(q(`is:open is:pr author:${user} status:failure`), headers, cfg.timeout), | |
| ]); | |
| const vm = this._buildViewModel(cfg, { notifications, openPRs, reviews, failing }); | |
| this.cache[id] = vm; | |
| this._saveCache(); | |
| this._send(id, vm); | |
| } catch (err) { | |
| // err.message is safe (HTTP status / abort / DNS); it never contains the | |
| // token or headers. Do NOT log the request options. | |
| this._fail(id, this._reason(err)); | |
| } | |
| }, | |
| _buildViewModel(cfg, { notifications, openPRs, reviews, failing }) { | |
| const notifCount = Array.isArray(notifications) ? notifications.length : 0; | |
| // Actionable items, prioritised: review requests, then failing CI, then | |
| // notification mentions. Deduped by URL, capped at maxActionable. | |
| const items = []; | |
| const seen = new Set(); | |
| const push = (kind, kindLabel, title, url, repo) => { | |
| if (!url || seen.has(url) || items.length >= cfg.maxActionable) return; | |
| seen.add(url); | |
| items.push({ kind, kindLabel, title: title || "(untitled)", url, repo }); | |
| }; | |
| for (const it of reviews.items || []) { | |
| push("review", "Review", it.title, it.html_url, this._repo(it)); | |
| } | |
| for (const it of failing.items || []) { | |
| push("ci", "CI failing", it.title, it.html_url, this._repo(it)); | |
| } | |
| for (const n of notifications || []) { | |
| if (["review_requested", "mention", "assign"].includes(n.reason)) { | |
| const url = (n.subject && n.subject.url) || (n.repository && n.repository.html_url); | |
| push("mention", "Mention", n.subject && n.subject.title, url, n.repository && n.repository.full_name); | |
| } | |
| } | |
| return { | |
| counts: { | |
| notifications: notifCount >= 50 ? "50+" : notifCount, | |
| openPRs: typeof openPRs.total_count === "number" ? openPRs.total_count : 0, | |
| failingCI: typeof failing.total_count === "number" ? failing.total_count : 0, | |
| }, | |
| items, | |
| stale: false, | |
| error: null, | |
| lastSuccess: Date.now(), | |
| }; | |
| }, | |
| // Serve cached data as stale if we have it; otherwise a bare error state. | |
| _fail(id, reason) { | |
| if (this.cache[id]) { | |
| this._send(id, { ...this.cache[id], stale: true, error: reason }); | |
| } else { | |
| this._send(id, { | |
| counts: null, | |
| items: [], | |
| stale: false, | |
| error: reason, | |
| lastSuccess: null, | |
| }); | |
| } | |
| }, | |
| _send(id, vm) { | |
| this.sendSocketNotification("GHD_DATA", { identifier: id, ...vm }); | |
| }, | |
| // ---- fetch with timeout/abort ------------------------------------------ | |
| async _getJson(url, headers, timeoutMs) { | |
| // AbortSignal.timeout aborts the request after timeoutMs (Node 18+). | |
| const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); | |
| if (!res.ok) { | |
| const err = new Error(`GitHub ${res.status}`); | |
| err.status = res.status; | |
| throw err; | |
| } | |
| return res.json(); | |
| }, | |
| _reason(err) { | |
| if (err && err.name === "TimeoutError") return "GitHub timed out"; | |
| if (err && err.status) return `GitHub error ${err.status}`; | |
| return "GitHub unreachable"; | |
| }, | |
| _repo(issue) { | |
| // repository_url looks like https://api.github.com/repos/owner/name | |
| if (issue.repository_url) return issue.repository_url.split("/repos/")[1] || ""; | |
| if (issue.html_url) { | |
| const m = issue.html_url.match(/github\.com\/([^/]+\/[^/]+)/); | |
| return m ? m[1] : ""; | |
| } | |
| return ""; | |
| }, | |
| // ---- disk cache --------------------------------------------------------- | |
| _loadCache() { | |
| try { | |
| return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); | |
| } catch { | |
| return {}; | |
| } | |
| }, | |
| _saveCache() { | |
| try { | |
| fs.writeFileSync(CACHE_FILE, JSON.stringify(this.cache), "utf8"); | |
| } catch (e) { | |
| console.log("[MMM-GitHubDashboard] could not persist cache:", e.message); | |
| } | |
| }, | |
| }); | |
| __MM_BOOTSTRAP_EOF__ | |
| _write "systemd/magicmirror.service" always <<'__MM_BOOTSTRAP_EOF__' | |
| # MagicMirror² — systemd *user* unit (serveronly mode). | |
| # | |
| # Runs as your user so it can read the fnox age key (~/.config/fnox) and the | |
| # mise-managed Node. `mise exec` puts node + fnox on PATH; `fnox exec` injects | |
| # GITHUB_TOKEN and the calendar URLs; then MagicMirror starts headless on :8080. | |
| # A separate kiosk browser (see README) points at http://localhost:8080. | |
| # | |
| # Assumes this repo lives at ~/mm and MagicMirror at ~/mm/MagicMirror. | |
| # Install: | |
| # mkdir -p ~/.config/systemd/user | |
| # ln -sf ~/mm/systemd/magicmirror.service ~/.config/systemd/user/magicmirror.service | |
| # loginctl enable-linger "$USER" # start at boot without login | |
| # systemctl --user daemon-reload | |
| # systemctl --user enable --now magicmirror | |
| [Unit] | |
| Description=MagicMirror2 portrait dashboard (serveronly) | |
| Documentation=https://docs.magicmirror.builders | |
| After=network-online.target | |
| Wants=network-online.target | |
| [Service] | |
| Type=simple | |
| WorkingDirectory=%h/mm | |
| # mise.toml is read from WorkingDirectory; fnox.toml too (root=true). | |
| ExecStart=%h/.local/bin/mise exec -- fnox exec -- node %h/mm/MagicMirror/serveronly | |
| Restart=always | |
| RestartSec=5 | |
| # Give the app a moment to shut down cleanly on restart/stop. | |
| TimeoutStopSec=15 | |
| # Keep logs readable in `journalctl --user -u magicmirror`. | |
| StandardOutput=journal | |
| StandardError=journal | |
| Environment=NODE_ENV=production | |
| # Light hardening that doesn't interfere with mise/fnox/node. | |
| NoNewPrivileges=true | |
| ProtectControlGroups=true | |
| ProtectKernelTunables=true | |
| [Install] | |
| WantedBy=default.target | |
| __MM_BOOTSTRAP_EOF__ | |
| } | |
| # -------------------------------------------------------------------------- | |
| # Lib-only guard: `MM_LIB_ONLY=1 source thisfile` defines helpers and stops. | |
| # -------------------------------------------------------------------------- | |
| if [[ "${MM_LIB_ONLY:-0}" == "1" ]]; then | |
| return 0 2>/dev/null || exit 0 | |
| fi | |
| # ========================================================================== | |
| # Argument parsing | |
| # ========================================================================== | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -y|--yes) ASSUME_YES=1; [[ "$DO_KIOSK" == "ask" ]] && DO_KIOSK="no"; shift ;; | |
| --username) GH_USERNAME="${2:-}"; shift 2 ;; | |
| --dir) TARGET="${2:-}"; shift 2 ;; | |
| --kiosk) DO_KIOSK="yes"; shift ;; | |
| --no-kiosk) DO_KIOSK="no"; shift ;; | |
| --output) KIOSK_OUTPUT="${2:-}"; shift 2 ;; | |
| --mm-tag) MM_TAG="${2:-}"; shift 2 ;; | |
| --overwrite) OVERWRITE=1; shift ;; | |
| -h|--help) sed -n '2,45p' "$0" 2>/dev/null || true; exit 0 ;; | |
| *) die "unknown argument: $1" ;; | |
| esac | |
| done | |
| # Expand a leading ~ in --dir if the shell didn't. | |
| TARGET="${TARGET/#\~/$HOME}" | |
| MM_HOME="$TARGET/MagicMirror" | |
| FNOX_DIR="$HOME/.config/fnox" | |
| AGE_FILE="$FNOX_DIR/age.txt" | |
| AGE_ENV="$FNOX_DIR/age.env" | |
| UNIT_DIR="$HOME/.config/systemd/user" | |
| UNIT_FILE="$UNIT_DIR/magicmirror.service" | |
| printf '%sMagicMirror portrait - one-file bootstrap%s\n' "$C_B" "$C_0" | |
| printf 'target: %s\n' "$TARGET" | |
| printf 'MagicMirror: %s (%s)\n' "$MM_HOME" "$MM_TAG" | |
| # ========================================================================== | |
| # 1. Place files | |
| # ========================================================================== | |
| mkdir -p "$TARGET" | |
| write_files | |
| # Test hook: stop after writing files. | |
| if [[ "${MM_WRITE_ONLY:-0}" == "1" ]]; then exit 0; fi | |
| cd "$TARGET" | |
| # ========================================================================== | |
| # 2. mise (install if missing) + toolchain | |
| # ========================================================================== | |
| step "Toolchain via mise (Node 22 + fnox + age)" | |
| MISE="$(command -v mise || true)" | |
| if [[ -z "$MISE" ]]; then | |
| for c in "$HOME/.local/bin/mise" /opt/homebrew/bin/mise /usr/local/bin/mise; do | |
| [[ -x "$c" ]] && { MISE="$c"; break; } | |
| done | |
| fi | |
| if [[ -z "$MISE" ]]; then | |
| warn "mise not found - installing from https://mise.run" | |
| curl -fsSL https://mise.run | sh | |
| MISE="$HOME/.local/bin/mise" | |
| fi | |
| [[ -x "$MISE" ]] || die "mise is not available at $MISE" | |
| ok "mise: $MISE" | |
| "$MISE" trust "$TARGET" >/dev/null 2>&1 || true | |
| "$MISE" install | |
| mrun() { "$MISE" exec -- "$@"; } | |
| mrun node --version >/dev/null || die "node unavailable after mise install" | |
| mrun fnox --version >/dev/null || die "fnox unavailable after mise install" | |
| ok "node $(mrun node --version) / fnox $(mrun fnox --version 2>/dev/null | head -n1)" | |
| # ========================================================================== | |
| # 3. age key + fnox recipient | |
| # ========================================================================== | |
| step "age encryption key for fnox" | |
| AGE_KEYGEN=() | |
| if mrun age-keygen --version >/dev/null 2>&1; then AGE_KEYGEN=("$MISE" exec -- age-keygen) | |
| elif have age-keygen; then AGE_KEYGEN=(age-keygen) | |
| elif have brew; then warn "installing age via Homebrew"; brew install age; AGE_KEYGEN=(age-keygen) | |
| else die "age-keygen unavailable (mise/brew both failed)"; fi | |
| mkdir -p "$FNOX_DIR"; chmod 700 "$FNOX_DIR" | |
| if [[ ! -f "$AGE_FILE" ]]; then | |
| "${AGE_KEYGEN[@]}" -o "$AGE_FILE" >/dev/null 2>&1 | |
| chmod 600 "$AGE_FILE"; ok "generated $AGE_FILE" | |
| else | |
| ok "reusing $AGE_FILE" | |
| fi | |
| AGE_PUB="$(grep -m1 'public key:' "$AGE_FILE" | awk '{print $NF}')" | |
| AGE_SEC="$(grep -m1 '^AGE-SECRET-KEY-' "$AGE_FILE")" | |
| [[ -n "$AGE_PUB" && -n "$AGE_SEC" ]] || die "could not read keys from $AGE_FILE" | |
| printf 'FNOX_AGE_KEY=%s\n' "$AGE_SEC" > "$AGE_ENV"; chmod 600 "$AGE_ENV" | |
| export FNOX_AGE_KEY="$AGE_SEC" | |
| rc=0; inject_age_recipient "$TARGET/fnox.toml" "$AGE_PUB" || rc=$? | |
| case $rc in | |
| 0) ok "fnox.toml recipient set" ;; | |
| 2) warn "fnox.toml already has recipients; ensure it includes $AGE_PUB" ;; | |
| *) warn "could not set recipient (rc=$rc); add recipients = [\"$AGE_PUB\"] to fnox.toml manually" ;; | |
| esac | |
| # ========================================================================== | |
| # 4. GitHub username | |
| # ========================================================================== | |
| step "GitHub username for the dashboard module" | |
| if [[ -z "$GH_USERNAME" && $HAVE_TTY -eq 1 && $ASSUME_YES -eq 0 ]]; then | |
| if grep -q "YOUR_GITHUB_USERNAME" "$TARGET/config/config.js"; then | |
| GH_USERNAME="$(ask 'GitHub username (enter to set later): ' '')" | |
| fi | |
| fi | |
| if [[ -n "$GH_USERNAME" ]]; then | |
| if out="$(set_github_username "$TARGET/config/config.js" "$GH_USERNAME")"; then | |
| [[ "$out" == "ALREADY_SET" ]] && ok "username already $GH_USERNAME" || ok "username set to $GH_USERNAME" | |
| else | |
| warn "could not set username; edit config/config.js by hand" | |
| fi | |
| else | |
| warn "username left as placeholder - edit config/config.js before first run" | |
| fi | |
| # ========================================================================== | |
| # 5. Clone MagicMirror | |
| # ========================================================================== | |
| step "Fetching MagicMirror $MM_TAG" | |
| if [[ -f "$MM_HOME/serveronly" ]]; then | |
| ok "already present at $MM_HOME" | |
| else | |
| have git || die "git not found. Install it (e.g. sudo apt install -y git) and re-run." | |
| git clone --depth 1 --branch "$MM_TAG" "$MM_REPO_URL" "$MM_HOME" | |
| ok "cloned into $MM_HOME" | |
| fi | |
| # ========================================================================== | |
| # 6. Install dependencies (headless - Electron binary skipped) | |
| # ========================================================================== | |
| step "Installing MagicMirror dependencies (serveronly)" | |
| # Pin Node explicitly: mise exec node@X provides that exact version (installing | |
| # if needed) and puts its bundled npm first on PATH, so a system Node in this | |
| # subdirectory can't be picked up (the usual Raspberry Pi EBADENGINE cause). | |
| ( cd "$MM_HOME" && ELECTRON_SKIP_BINARY_DOWNLOAD=1 "$MISE" exec "node@$NODE_VERSION" -- npm install --no-audit --no-fund ) | |
| ok "dependencies installed on node $("$MISE" exec "node@$NODE_VERSION" -- node --version)" | |
| # ========================================================================== | |
| # 7. Link repo config/module into the checkout | |
| # ========================================================================== | |
| step "Linking config, theme and module" | |
| "$MISE" run link | |
| ok "linked" | |
| # ========================================================================== | |
| # 8. Secrets (optional; needs a terminal) | |
| # ========================================================================== | |
| step "Secrets (age-encrypted via fnox; never in the repo)" | |
| if [[ $ASSUME_YES -eq 1 || $HAVE_TTY -eq 0 ]]; then | |
| warn "non-interactive: skipping secrets. Set them later from $TARGET:" | |
| warn " export FNOX_AGE_KEY=\$(grep AGE-SECRET-KEY $AGE_FILE)" | |
| warn " $MISE exec -- fnox set GITHUB_TOKEN --provider age" | |
| else | |
| echo " (values are hidden; press enter to skip one, type 's' to skip the rest)" | |
| for pair in \ | |
| "GITHUB_TOKEN|fine-grained PAT, read-only" \ | |
| "ICAL_APPLE|iCloud calendar ICS URL" \ | |
| "ICAL_APPLE_BDAY|iCloud birthdays ICS URL" \ | |
| "ICAL_GOOGLE|Google calendar ICS URL" \ | |
| "ICAL_GOOGLE_BDAY|Google birthdays ICS URL"; do | |
| name="${pair%%|*}"; desc="${pair#*|}" | |
| a="$(ask " set $name ($desc)? [y/N/s]: " 'n')" | |
| case "$a" in | |
| s|skip) warn "skipping remaining secrets"; break ;; | |
| y|Y|yes) | |
| rc=0; "$MISE" exec -- fnox set "$name" --provider age </dev/tty || rc=$? | |
| [[ $rc -ne 0 ]] && warn "fnox set $name failed (rc=$rc) - continuing" ;; | |
| *) : ;; | |
| esac | |
| done | |
| fi | |
| # ========================================================================== | |
| # 9. Validate | |
| # ========================================================================== | |
| step "Validating config against MagicMirror's own checker" | |
| if "$MISE" run validate; then ok "config valid"; else warn "validator reported issues (see above)"; fi | |
| # ========================================================================== | |
| # 10. systemd user service | |
| # ========================================================================== | |
| step "Installing the systemd --user service" | |
| mkdir -p "$UNIT_DIR" | |
| render_unit "$MISE" "$TARGET" "$MM_HOME" "$AGE_ENV" > "$UNIT_FILE" | |
| if systemctl --user show-environment >/dev/null 2>&1; then | |
| loginctl enable-linger "$USER" >/dev/null 2>&1 || warn "could not enable linger (needed to run at boot)" | |
| systemctl --user daemon-reload | |
| systemctl --user enable --now magicmirror | |
| sleep 2 | |
| if systemctl --user is-active --quiet magicmirror; then ok "magicmirror.service is active" | |
| else warn "service not active yet - check: journalctl --user -u magicmirror -e"; fi | |
| else | |
| warn "no systemd user session here. Unit written to $UNIT_FILE. On the Pi's own session run:" | |
| warn " loginctl enable-linger \"$USER\" && systemctl --user daemon-reload && systemctl --user enable --now magicmirror" | |
| fi | |
| # ========================================================================== | |
| # 11. Kiosk + rotation (optional) | |
| # ========================================================================== | |
| step "Display kiosk + rotation (labwc autostart)" | |
| want="$DO_KIOSK" | |
| if [[ "$want" == "ask" ]]; then | |
| a="$(ask ' configure Chromium kiosk + screen rotation now? [y/N]: ' 'n')" | |
| [[ "$a" =~ ^[yY] ]] && want="yes" || want="no" | |
| fi | |
| if [[ "$want" == "yes" ]]; then | |
| if [[ -z "$KIOSK_OUTPUT" ]] && have wlr-randr; then | |
| KIOSK_OUTPUT="$(wlr-randr 2>/dev/null | awk 'NR==1{print $1}')" | |
| fi | |
| autostart="$HOME/.config/labwc/autostart" | |
| install_autostart_block "$autostart" "$KIOSK_OUTPUT" "$ROTATE_TRANSFORM" "http://localhost:8080" | |
| if [[ -n "$KIOSK_OUTPUT" ]]; then | |
| ok "labwc autostart updated (output=$KIOSK_OUTPUT, transform=$ROTATE_TRANSFORM)" | |
| else | |
| warn "autostart written but output unknown - run 'wlr-randr' and edit $autostart (or re-run with --output NAME)" | |
| fi | |
| warn "log out/in or reboot for labwc autostart to take effect" | |
| else | |
| ok "skipped. Later: bash mm.sh --kiosk [--output HDMI-A-1] (or edit ~/.config/labwc/autostart)" | |
| fi | |
| # ========================================================================== | |
| # Done | |
| # ========================================================================== | |
| printf '\n%sAll set.%s The mirror serves on http://localhost:8080\n' "$C_G" "$C_0" | |
| cat <<DONE | |
| Service: systemctl --user status magicmirror | |
| Logs: $MISE run logs (journalctl --user -u magicmirror -f) | |
| Restart: systemctl --user restart magicmirror | |
| Re-run: bash mm.sh (safe; keeps your secrets + config) | |
| Set/att secrets later (from $TARGET): | |
| export FNOX_AGE_KEY=\$(grep AGE-SECRET-KEY "$AGE_FILE") | |
| $MISE exec -- fnox set GITHUB_TOKEN --provider age | |
| $MISE exec -- fnox list | |
| DONE |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # setup.sh — one command to provision the MagicMirror portrait dashboard. | |
| # | |
| # Assumes: mise + Homebrew already installed, Raspberry Pi OS (Bookworm) with a | |
| # Wayland/labwc session. Run it from inside this repo: | |
| # | |
| # ./setup.sh | |
| # | |
| # What it does (each step is idempotent and safe to re-run): | |
| # 1. mise trust + install -> Node 22 and the fnox secrets CLI | |
| # 2. age keypair -> ~/.config/fnox/age.txt (+ recipient into fnox.toml) | |
| # 3. GitHub username -> written into config/config.js | |
| # 4. clone MagicMirror -> <repo>/MagicMirror at a pinned tag | |
| # 5. npm install (headless) -> serveronly deps, electron binary skipped | |
| # 6. mise run link -> symlink config/css/module into the checkout | |
| # 7. secrets (optional) -> prompt for GITHUB_TOKEN + calendar URLs | |
| # 8. mise run validate -> MagicMirror's own config checker | |
| # 9. systemd --user service -> enable-linger + enable --now | |
| # 10. kiosk (optional) -> labwc autostart: rotate + Chromium kiosk | |
| # | |
| # Nothing secret is ever written into the repo. The GitHub token stays in the | |
| # age-encrypted fnox store and is injected only at run time via `fnox exec`. | |
| # | |
| # Flags: | |
| # -y, --yes non-interactive; accept defaults, skip secret prompts | |
| # --username NAME set the GitHub username without prompting | |
| # --kiosk configure the labwc kiosk/rotation step | |
| # --no-kiosk skip the kiosk step (default when non-interactive) | |
| # --mm-tag vX.Y.Z MagicMirror tag to clone (default: v2.37.0) | |
| # --output NAME Wayland output for rotation (e.g. HDMI-A-1) | |
| # -h, --help | |
| set -euo pipefail | |
| # --------------------------------------------------------------------------- | |
| # Defaults / flags | |
| # --------------------------------------------------------------------------- | |
| MM_TAG_DEFAULT="v2.37.0" | |
| MM_REPO_URL="https://github.com/MagicMirrorOrg/MagicMirror.git" | |
| ROTATE_TRANSFORM="90" # portrait: 90 = clockwise. Use 270 to flip. | |
| ASSUME_YES=0 | |
| DO_KIOSK="ask" # ask | yes | no | |
| GH_USERNAME="${GH_USERNAME:-}" | |
| MM_TAG="$MM_TAG_DEFAULT" | |
| KIOSK_OUTPUT="${KIOSK_OUTPUT:-}" | |
| # --------------------------------------------------------------------------- | |
| # Pretty output | |
| # --------------------------------------------------------------------------- | |
| if [[ -t 1 ]]; then | |
| C_B=$'\033[1;34m'; C_G=$'\033[1;32m'; C_Y=$'\033[1;33m'; C_R=$'\033[1;31m'; C_0=$'\033[0m' | |
| else | |
| C_B=""; C_G=""; C_Y=""; C_R=""; C_0="" | |
| fi | |
| step() { printf '\n%s==>%s %s\n' "$C_B" "$C_0" "$*"; } | |
| ok() { printf '%s ok%s %s\n' "$C_G" "$C_0" "$*"; } | |
| warn() { printf '%swarn%s %s\n' "$C_Y" "$C_0" "$*" >&2; } | |
| die() { printf '%serror%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } | |
| have() { command -v "$1" >/dev/null 2>&1; } | |
| # =========================================================================== | |
| # Pure helpers (unit-tested; see the lib-only guard below) | |
| # =========================================================================== | |
| # Ensure the age provider in fnox.toml carries our recipient public key. | |
| # Handles the shipped state `age = { type = "age" }` and is idempotent. | |
| inject_age_recipient() { | |
| local file="$1" pub="$2" | |
| [[ -f "$file" ]] || { echo "inject_age_recipient: no $file" >&2; return 1; } | |
| if grep -q "$pub" "$file"; then | |
| return 0 # already present | |
| fi | |
| if grep -Eq '^[[:space:]]*age[[:space:]]*=.*recipients' "$file"; then | |
| echo "RECIPIENTS_EXIST" # someone else configured it | |
| return 2 | |
| fi | |
| # Turn `age = { type = "age" }` into the same with a recipients array. | |
| sed -i.bak -E \ | |
| "s|^([[:space:]]*age[[:space:]]*=[[:space:]]*\{[[:space:]]*type[[:space:]]*=[[:space:]]*\"age\")[[:space:]]*\}|\1, recipients = [\"$pub\"] }|" \ | |
| "$file" | |
| rm -f "$file.bak" | |
| grep -q "$pub" "$file" | |
| } | |
| # Replace the username placeholder in config.js. Idempotent. | |
| set_github_username() { | |
| local file="$1" user="$2" | |
| [[ -f "$file" ]] || { echo "set_github_username: no $file" >&2; return 1; } | |
| [[ "$user" =~ ^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$ ]] || { | |
| echo "invalid GitHub username: $user" >&2; return 1; } | |
| if grep -q "YOUR_GITHUB_USERNAME" "$file"; then | |
| sed -i.bak "s/YOUR_GITHUB_USERNAME/$user/g" "$file"; rm -f "$file.bak" | |
| return 0 | |
| fi | |
| echo "ALREADY_SET"; return 0 # placeholder already replaced | |
| } | |
| # Emit a systemd user unit with absolute paths resolved at install time. | |
| render_unit() { | |
| local mise_bin="$1" repo="$2" mm_home="$3" age_env="$4" | |
| cat <<UNIT | |
| # MagicMirror2 portrait dashboard (serveronly) — generated by setup.sh | |
| # Regenerate with: ./setup.sh (this file is overwritten, so edit setup.sh) | |
| [Unit] | |
| Description=MagicMirror2 portrait dashboard (serveronly) | |
| Documentation=https://docs.magicmirror.builders | |
| After=network-online.target | |
| Wants=network-online.target | |
| [Service] | |
| Type=simple | |
| WorkingDirectory=$repo | |
| # age.env supplies FNOX_AGE_KEY so fnox can decrypt secrets headlessly. | |
| # The leading '-' makes it optional: the mirror still starts if it is absent. | |
| EnvironmentFile=-$age_env | |
| Environment=NODE_ENV=production | |
| # mise puts node+fnox on PATH; fnox injects secrets; MagicMirror runs headless. | |
| ExecStart=$mise_bin exec -- fnox exec -- node $mm_home/serveronly | |
| Restart=always | |
| RestartSec=5 | |
| TimeoutStopSec=15 | |
| StandardOutput=journal | |
| StandardError=journal | |
| NoNewPrivileges=true | |
| ProtectControlGroups=true | |
| ProtectKernelTunables=true | |
| [Install] | |
| WantedBy=default.target | |
| UNIT | |
| } | |
| # Write an idempotent, marker-delimited block into a labwc autostart file, | |
| # backing up any existing file first. | |
| install_autostart_block() { | |
| local file="$1" output="$2" transform="$3" url="$4" | |
| local begin="# >>> magicmirror-portrait >>>" | |
| local end="# <<< magicmirror-portrait <<<" | |
| local rotate_line | |
| if [[ -n "$output" ]]; then | |
| rotate_line="wlr-randr --output $output --transform $transform &" | |
| else | |
| rotate_line="# wlr-randr --output <YOUR-OUTPUT> --transform $transform & # set --output (run: wlr-randr)" | |
| fi | |
| local block | |
| block="$begin | |
| # Screen rotation + Chromium kiosk for the MagicMirror dashboard. | |
| $rotate_line | |
| chromium-browser --kiosk --noerrdialogs --disable-infobars --incognito \\ | |
| --check-for-update-interval=31536000 \"$url\" & | |
| $end" | |
| mkdir -p "$(dirname "$file")" | |
| if [[ -f "$file" ]] && grep -qF "$begin" "$file"; then | |
| # Replace the existing managed block in place. | |
| local tmp; tmp="$(mktemp)" | |
| awk -v b="$begin" -v e="$end" -v repl="$block" ' | |
| $0==b {print repl; skip=1; next} | |
| $0==e {skip=0; next} | |
| !skip {print} | |
| ' "$file" > "$tmp" | |
| mv "$tmp" "$file" | |
| else | |
| [[ -f "$file" ]] && cp -p "$file" "$file.bak.$(date +%Y%m%d%H%M%S)" | |
| { [[ -f "$file" ]] && echo; echo "$block"; } >> "$file" | |
| fi | |
| chmod +x "$file" 2>/dev/null || true | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Lib-only guard: `source setup.sh` with MM_SETUP_LIB_ONLY=1 defines the | |
| # helpers above and stops here, so they can be tested in isolation. | |
| # --------------------------------------------------------------------------- | |
| if [[ "${MM_SETUP_LIB_ONLY:-0}" == "1" ]]; then | |
| return 0 2>/dev/null || exit 0 | |
| fi | |
| # =========================================================================== | |
| # Argument parsing | |
| # =========================================================================== | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -y|--yes) ASSUME_YES=1; [[ "$DO_KIOSK" == "ask" ]] && DO_KIOSK="no"; shift ;; | |
| --username) GH_USERNAME="${2:-}"; shift 2 ;; | |
| --kiosk) DO_KIOSK="yes"; shift ;; | |
| --no-kiosk) DO_KIOSK="no"; shift ;; | |
| --mm-tag) MM_TAG="${2:-}"; shift 2 ;; | |
| --output) KIOSK_OUTPUT="${2:-}"; shift 2 ;; | |
| -h|--help) sed -n '2,40p' "$0"; exit 0 ;; | |
| *) die "unknown argument: $1" ;; | |
| esac | |
| done | |
| ask() { # ask "prompt" "default" -> echoes answer; auto-answers default with -y | |
| local prompt="$1" def="${2:-}" | |
| if [[ $ASSUME_YES -eq 1 ]]; then echo "$def"; return; fi | |
| local ans; read -r -p "$prompt" ans || true | |
| echo "${ans:-$def}" | |
| } | |
| # =========================================================================== | |
| # Resolve paths | |
| # =========================================================================== | |
| REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| cd "$REPO" | |
| [[ -f "$REPO/config/config.js" && -f "$REPO/mise.toml" ]] || \ | |
| die "run this from inside the repo (config/config.js + mise.toml not found)" | |
| MM_HOME="$REPO/MagicMirror" | |
| FNOX_DIR="$HOME/.config/fnox" | |
| AGE_FILE="$FNOX_DIR/age.txt" | |
| AGE_ENV="$FNOX_DIR/age.env" | |
| UNIT_DIR="$HOME/.config/systemd/user" | |
| UNIT_FILE="$UNIT_DIR/magicmirror.service" | |
| printf '%sMagicMirror portrait — setup%s\n' "$C_B" "$C_0" | |
| printf 'repo: %s\n' "$REPO" | |
| printf 'MagicMirror: %s (%s)\n' "$MM_HOME" "$MM_TAG" | |
| # =========================================================================== | |
| # 0. Preflight | |
| # =========================================================================== | |
| step "Preflight" | |
| [[ "$(uname -s)" == "Linux" ]] || warn "not Linux — the systemd/kiosk steps are Pi-specific" | |
| have mise || die "mise not found on PATH. Install it first: https://mise.jdx.dev" | |
| have git || die "git not found. Install with: brew install git (or apt install git)" | |
| MISE_BIN="$(command -v mise)" | |
| ok "mise at $MISE_BIN" | |
| # =========================================================================== | |
| # 1. Toolchain | |
| # =========================================================================== | |
| step "Installing toolchain (Node 22 + fnox) via mise" | |
| mise trust "$REPO" >/dev/null 2>&1 || true | |
| mise install | |
| mrun() { mise exec -- "$@"; } # run a tool through mise | |
| mrun node --version >/dev/null || die "node not available after mise install" | |
| mrun fnox --version >/dev/null || die "fnox not available after mise install" | |
| ok "node $(mrun node --version) · fnox $(mrun fnox --version 2>/dev/null | head -n1)" | |
| # =========================================================================== | |
| # 2. age key + fnox recipient | |
| # =========================================================================== | |
| step "Setting up the age encryption key for fnox" | |
| if ! have age-keygen; then | |
| warn "age-keygen missing — installing 'age' via Homebrew" | |
| have brew || die "Homebrew not found and age-keygen missing. Install age manually." | |
| brew install age | |
| fi | |
| mkdir -p "$FNOX_DIR"; chmod 700 "$FNOX_DIR" | |
| if [[ ! -f "$AGE_FILE" ]]; then | |
| age-keygen -o "$AGE_FILE" >/dev/null 2>&1 | |
| chmod 600 "$AGE_FILE" | |
| ok "generated $AGE_FILE" | |
| else | |
| ok "reusing existing $AGE_FILE" | |
| fi | |
| AGE_PUB="$(grep -m1 'public key:' "$AGE_FILE" | awk '{print $NF}')" | |
| AGE_SEC="$(grep -m1 '^AGE-SECRET-KEY-' "$AGE_FILE")" | |
| [[ -n "$AGE_PUB" && -n "$AGE_SEC" ]] || die "could not read keys from $AGE_FILE" | |
| # Runtime decryption key for the headless service + this script. | |
| printf 'FNOX_AGE_KEY=%s\n' "$AGE_SEC" > "$AGE_ENV"; chmod 600 "$AGE_ENV" | |
| export FNOX_AGE_KEY="$AGE_SEC" | |
| if inject_age_recipient "$REPO/fnox.toml" "$AGE_PUB"; then | |
| ok "fnox.toml recipient set ($AGE_PUB)" | |
| else | |
| rc=$? | |
| [[ $rc -eq 2 ]] && warn "fnox.toml already has recipients; ensure it includes $AGE_PUB" | |
| fi | |
| # =========================================================================== | |
| # 3. GitHub username | |
| # =========================================================================== | |
| step "GitHub username for the dashboard module" | |
| if [[ -z "$GH_USERNAME" ]]; then | |
| if grep -q "YOUR_GITHUB_USERNAME" "$REPO/config/config.js"; then | |
| GH_USERNAME="$(ask 'GitHub username (enter to set later): ' '')" | |
| fi | |
| fi | |
| if [[ -n "$GH_USERNAME" ]]; then | |
| if out="$(set_github_username "$REPO/config/config.js" "$GH_USERNAME")"; then | |
| [[ "$out" == "ALREADY_SET" ]] && ok "username already set (leaving as-is)" \ | |
| || ok "username set to $GH_USERNAME" | |
| else | |
| warn "could not set username automatically; edit config/config.js by hand" | |
| fi | |
| else | |
| warn "username left as placeholder — set it in config/config.js before first run" | |
| fi | |
| # =========================================================================== | |
| # 4. Clone MagicMirror | |
| # =========================================================================== | |
| step "Fetching MagicMirror $MM_TAG" | |
| if [[ -f "$MM_HOME/serveronly" ]]; then | |
| ok "MagicMirror already present at $MM_HOME" | |
| else | |
| git clone --depth 1 --branch "$MM_TAG" "$MM_REPO_URL" "$MM_HOME" | |
| ok "cloned into $MM_HOME" | |
| fi | |
| # =========================================================================== | |
| # 5. Install dependencies (headless — no Electron binary needed) | |
| # =========================================================================== | |
| step "Installing MagicMirror dependencies (serveronly)" | |
| ( cd "$MM_HOME" && ELECTRON_SKIP_BINARY_DOWNLOAD=1 mise exec -- npm install --no-audit --no-fund ) | |
| ok "dependencies installed" | |
| # =========================================================================== | |
| # 6. Link repo config/module into the checkout | |
| # =========================================================================== | |
| step "Linking config, theme and module into the checkout" | |
| mise run link | |
| ok "linked" | |
| # =========================================================================== | |
| # 7. Secrets (optional, interactive) | |
| # =========================================================================== | |
| step "Secrets (stored age-encrypted in fnox; never in the repo)" | |
| setsecret() { # setsecret NAME "description" | |
| local name="$1" desc="$2" | |
| local a; a="$(ask " set $name ($desc)? [y/N/skip-all]: " 'n')" | |
| case "$a" in | |
| s|skip|skip-all) return 9 ;; | |
| y|Y|yes) mise exec -- fnox set "$name" --provider age ;; | |
| *) return 0 ;; | |
| esac | |
| } | |
| if [[ $ASSUME_YES -eq 1 ]]; then | |
| warn "non-interactive: skipping secrets. Set them later, e.g.:" | |
| warn " export FNOX_AGE_KEY=\$(grep AGE-SECRET-KEY $AGE_FILE)" | |
| warn " mise exec -- fnox set GITHUB_TOKEN --provider age" | |
| else | |
| echo " (fnox prompts hidden; press enter at each to skip, or type 's' to skip all)" | |
| for pair in \ | |
| "GITHUB_TOKEN|fine-grained PAT, read-only" \ | |
| "ICAL_APPLE|iCloud calendar ICS URL" \ | |
| "ICAL_APPLE_BDAY|iCloud birthdays ICS URL" \ | |
| "ICAL_GOOGLE|Google calendar ICS URL" \ | |
| "ICAL_GOOGLE_BDAY|Google birthdays ICS URL"; do | |
| rc=0; setsecret "${pair%%|*}" "${pair#*|}" || rc=$? | |
| if [[ $rc -eq 9 ]]; then warn "skipping remaining secrets"; break; fi | |
| if [[ $rc -ne 0 ]]; then warn "fnox set ${pair%%|*} failed (rc=$rc) — continuing"; fi | |
| done | |
| fi | |
| # =========================================================================== | |
| # 8. Validate config | |
| # =========================================================================== | |
| step "Validating config against MagicMirror's own checker" | |
| if mise run validate; then | |
| ok "config valid" | |
| else | |
| warn "validator reported issues — review the output above" | |
| fi | |
| # =========================================================================== | |
| # 9. systemd user service | |
| # =========================================================================== | |
| step "Installing the systemd --user service" | |
| if ! systemctl --user show-environment >/dev/null 2>&1; then | |
| warn "no systemd user session detected (are you on the Pi's own session?)." | |
| warn "Unit written to $UNIT_FILE; enable it later with:" | |
| warn " loginctl enable-linger \"$USER\" && systemctl --user daemon-reload && systemctl --user enable --now magicmirror" | |
| mkdir -p "$UNIT_DIR" | |
| render_unit "$MISE_BIN" "$REPO" "$MM_HOME" "$AGE_ENV" > "$UNIT_FILE" | |
| else | |
| mkdir -p "$UNIT_DIR" | |
| render_unit "$MISE_BIN" "$REPO" "$MM_HOME" "$AGE_ENV" > "$UNIT_FILE" | |
| loginctl enable-linger "$USER" >/dev/null 2>&1 || warn "could not enable linger (need it to run at boot)" | |
| systemctl --user daemon-reload | |
| systemctl --user enable --now magicmirror | |
| sleep 2 | |
| if systemctl --user is-active --quiet magicmirror; then | |
| ok "magicmirror.service is active" | |
| else | |
| warn "service not active yet — check: journalctl --user -u magicmirror -e" | |
| fi | |
| fi | |
| # =========================================================================== | |
| # 10. Kiosk + rotation (optional) | |
| # =========================================================================== | |
| step "Display kiosk + rotation (labwc autostart)" | |
| want_kiosk="$DO_KIOSK" | |
| if [[ "$want_kiosk" == "ask" ]]; then | |
| a="$(ask ' configure Chromium kiosk + screen rotation now? [y/N]: ' 'n')" | |
| [[ "$a" =~ ^[yY] ]] && want_kiosk="yes" || want_kiosk="no" | |
| fi | |
| if [[ "$want_kiosk" == "yes" ]]; then | |
| # Try to auto-detect the output name if we're in a Wayland session. | |
| if [[ -z "$KIOSK_OUTPUT" ]] && have wlr-randr; then | |
| KIOSK_OUTPUT="$(wlr-randr 2>/dev/null | awk 'NR==1{print $1}')" | |
| fi | |
| autostart="$HOME/.config/labwc/autostart" | |
| install_autostart_block "$autostart" "$KIOSK_OUTPUT" "$ROTATE_TRANSFORM" "http://localhost:8080" | |
| if [[ -n "$KIOSK_OUTPUT" ]]; then | |
| ok "labwc autostart updated ($autostart), output=$KIOSK_OUTPUT transform=$ROTATE_TRANSFORM" | |
| else | |
| warn "labwc autostart written but output name unknown." | |
| warn "Run 'wlr-randr' on the Pi, then edit $autostart (or re-run with --output NAME)." | |
| fi | |
| warn "log out/in (or reboot) for labwc autostart to take effect." | |
| else | |
| ok "skipped. To do it later: ./setup.sh --kiosk [--output HDMI-A-1]" | |
| fi | |
| # =========================================================================== | |
| # Done | |
| # =========================================================================== | |
| printf '\n%sSetup complete.%s\n' "$C_G" "$C_0" | |
| cat <<DONE | |
| Service: systemctl --user status magicmirror | |
| Logs: mise run logs (or: journalctl --user -u magicmirror -f) | |
| Restart: systemctl --user restart magicmirror | |
| Validate: mise run validate | |
| Secrets later (run from this repo): | |
| export FNOX_AGE_KEY=\$(grep AGE-SECRET-KEY "$AGE_FILE") | |
| mise exec -- fnox set GITHUB_TOKEN --provider age | |
| mise exec -- fnox list | |
| The mirror serves on http://localhost:8080. Point the kiosk browser there. | |
| DONE |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment