NAS Workflow — mount, auto-sort Downloads, archive code, direct-save screenshots
Everything that touches /Volumes/media — how it gets mounted, what auto-sorts onto it, how stale code projects get archived, and where screenshots land.
Everything that touches the TrueNAS share at /Volumes/media — how it gets mounted, what auto-sorts onto it, how stale code projects get archived to it, and where screenshots land. For the Time Machine deep-dive, see Backup.
Mounting the share
The share smb://<USER>@<NAS-IP>/media is mounted at /Volumes/media by a hidden Finder Login Item. install.sh renders nas/truenas-media.inetloc.template with your real NAS_HOST / NAS_USER from ~/.config/dotfiles/local.env, drops it under ~/Library/Application Support/dotfiles/, and registers it as a Login Item. Credentials come from the macOS Keychain, saved on the first manual Finder mount.
osascript -e 'mount volume "smb://<USER>@<NAS-IP>/media"' Retry layer — nas-mount + LaunchAgent
The .inetloc Login Item fires the moment you log in — often before Wi-Fi has associated. When that happens Finder errors out and never retries, leaving you staring at an unmounted share until you click media in the Finder sidebar manually. bin/nas-mount is the fix.
It checks whether /Volumes/media is already mounted (instant early-out). If not, it asks Finder to mount the share via osascript — the same path the .inetloc uses, so the saved Keychain credential just works. No mount_smbfs, no Auth-77, no mkdir race on /Volumes. Six attempts with 15 s between, ~90 s total budget — comfortably covering Wi-Fi association + DHCP + mDNS at login.
A LaunchAgent (com.prometheus.nas-mount.plist) wraps the script and fires it at every login. Both the .inetloc and the agent run on login; whichever wins the race, the other no-ops.
nas-mount # retry until mounted, ~90 s budget
nas-mount -1 # one attempt, no retry loop
nas-mount -v # verbose: tee log to stdout
# fires automatically at login via com.prometheus.nas-mount LaunchAgent
launchctl start com.prometheus.nas-mount # kick by hand
tail -f ~/.nas-mount/nas-mount.log # watch the chain Exit codes: 0 = mounted (already or just now), 1 = NAS not configured in local.env, 2 = retries exhausted. Installer wires both pieces only when HAS_NAS=true.
Downloads auto-sort — sort-downloads
A LaunchAgent watches ~/Downloads via WatchPaths and fires bin/sort-downloads whenever a file appears. The script classifies each file by extension (or name, for Screenshot *) and routes it to the matching category folder on the NAS. Plist keys: WatchPaths, ThrottleInterval=30, RunAtLoad=true, StartOnMount=true, StartInterval=300.
screenshots · PNG / JPEG named "Screenshot *" or "Screen Shot *"
Pictures · jpg jpeg png gif webp heic heif tiff bmp svg raw …
PDFs · pdf
Documents · doc docx xls xlsx ppt pptx odt txt md rtf csv epub …
Installers · dmg pkg iso mpkg
Movies · mp4 mov mkv webm avi m4v flv wmv
Music · mp3 m4a wav flac ogg aac aiff
Archives · zip tar gz tgz bz2 7z rar xz zst
Other · everything else Safety: bails cleanly if the NAS isn’t mounted, skips partial downloads (.crdownload, .part, …), waits 30 s for in-flight files to settle, silently ignores macOS metadata (.DS_Store, .localized, AppleDouble shadows). Cross-volume copy uses cp -Xp into a .partial-$$ temp file, then renames to final, then unlinks source — a NAS dropout mid-copy never strands a file half-way.
Reconnect handling. At boot the agent’s RunAtLoad fires before the NAS has come up, so it bails (“NAS not mounted — skipping sweep”) and leaves files in place. The chain is restarted by nas-mount: once it successfully mounts the share, it directly execs sort-downloads to clear the backlog. We don’t rely on the plist’s StartOnMount for this — verified empirically that the disk-arbitration event launchd watches isn’t always emitted for SMB shares mounted via Finder + osascript. The key stays on the plist as belt-and-suspenders (it’s idempotent), but the deterministic chain is the direct exec from nas-mount.
sort-downloads -n # dry-run, just print decisions
sort-downloads -v # verbose: log skip reasons
sort-downloads # one sweep (what launchd calls)
tail -f ~/.sort-download/sort-downloads.log Screenshots direct to NAS
Configure your screenshot tool (Shottr, the built-in ⌘⇧4, CleanShot, whatever) to save directly to /Volumes/media/screenshots/. Bypasses the LaunchAgent permission problem entirely, and every capture is on the NAS the moment you press the shutter.
defaults write com.apple.screencapture location /Volumes/media/screenshots/
killall SystemUIServer For Shottr: Preferences → General → Save to → /Volumes/media/screenshots/. The folder must exist (one-time mkdir -p /Volumes/media/screenshots if it doesn’t).
Code archive — archive-project
Manual tool that recursively scans ~/Code/ for git repos and offers to move stale ones to /Volumes/media/code/archived/, preserving the original folder layout. Run when you want to free SSD space without losing finished work.
Decision logic
A project is offered as a candidate only when all three of these are true:
- It’s a regular git repo (
.git/directory). Worktrees, submodules, and repos nested inside another repo are skipped. - The working tree is fully clean —
git status --porcelainprints nothing. Any uncommitted change — staged, unstaged, or untracked — means the project stays put, no matter how old. - The last commit is older than
ARCHIVE_AFTER_MONTHSmonths (default1; tweakable at the top ofbin/archive-projector via--months=N).
archive-project # interactive, asks Y/N/q per candidate
archive-project -n # dry-run (preview only)
archive-project --months=3 # stricter: only 3+ months old
archive-project -y # yes to all candidates
archive-project --keep-deps # don't strip node_modules etc.
archive-project --man # open the man page
archive-project -h # short help What gets stripped before the move
By default the script blows away universally regeneratable build output anywhere in the project tree — before moving anything. Saves multiple GB of SMB transfer on RN / Flutter / Node monorepos. --keep-deps disables this.
# JS / Node
node_modules .next .nuxt .svelte-kit .astro .turbo .vite .parcel-cache .cache
# iOS / Xcode / CocoaPods
Pods DerivedData
# Android / Gradle (RN + Flutter both use this)
.gradle
# React Native / Expo
.expo .expo-shared
# Flutter / Dart
.dart_tool
# Python
**pycache** .pytest_cache .mypy_cache .ruff_cache Deliberately left alone: build/, dist/, out/, target/, venv/ — their names are too generic to delete safely by default. Run clean-node-modules or strip them by hand first if you want them gone too.
Atomic move semantics
The “move” is copy-then-remove, never mv:
cp -RXp src dst— recursive, no extended attributes (so SMB doesn’t choke oncom.apple.provenance/com.apple.quarantine), preserves mode + mtime.- If the copy completes end-to-end, the SSD source is removed with
rm -rf. - If the copy fails, the partial destination is wiped and the SSD source is kept for the next try.
A NAS dropout or full-disk error mid-move cannot strand you with half a project on each side.
Where to tweak
ARCHIVE_AFTER_MONTHS=1
CODE_DIR="$HOME/Code"
ARCHIVE_DIR="/Volumes/media/code/archived"
NAS_MOUNT="/Volumes/media"
LOG="$HOME/.archive-project/archive.log" Time Machine to TrueNAS
Monthly Time Machine backups to a separate SMB share (smb://<USER>@<NAS-IP>/timemachine) are managed by a root LaunchDaemon, not the user-scoped tools on this page. The full setup — share creation on TrueNAS, replacing the hourly schedule, helper commands (tm-status, tm-backup), exclusion rules — lives on the dedicated Backup page.
Cheat sheet
cd /Volumes/media # jump in (also: alias `media`)
open /Volumes/media # open in Finder
nas-mount # re-mount with retries
nas-mount -1 # one attempt, no retry
sort-downloads -n # preview Downloads auto-sort
archive-project -n # preview code archive candidates
archive-project --months=6 # stricter one-off threshold
tail -f ~/.nas-mount/nas-mount.log # mount + chain history
tail -f ~/.sort-download/sort-downloads.log # auto-sort activity
tail -f ~/.archive-project/archive.log # archive history
osascript -e 'mount volume "smb://<USER>@<NAS-IP>/media"' # re-mount by hand