Skip to content

Instantly share code, notes, and snippets.

@andrewmcodes
Last active July 23, 2026 02:20
Show Gist options
  • Select an option

  • Save andrewmcodes/dba977e072aebbe01f41a9db02881ee9 to your computer and use it in GitHub Desktop.

Select an option

Save andrewmcodes/dba977e072aebbe01f41a9db02881ee9 to your computer and use it in GitHub Desktop.
MM2 Conf
#!/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
#!/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
#!/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