Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@

### Shell Scripts

- Max 30 lines per file
- Use `set -euo pipefail` for scripts
- Executable scripts (`scripts/*.sh`) start with `set -euo pipefail`
- Sourced files must NOT set shell options: `lib/*.sh` and everything under
`DreamcoderShell/.config/shell/` run inside the caller's shell, and `errexit`/`nounset`
there closes an interactive terminal on the first failing command
- Shell fragments loaded by interactive shells (aliases, small functions) stay short;
scripts and libraries have one purpose per file and functions that fit on a screen
- Quote all variables: `"${var}"`
- Use `[[ ]]` instead of `[ ]` for tests
- Pass values to inline Python or other interpreters through arguments, never by
interpolating them into source text

### Modularity

Expand Down
22 changes: 20 additions & 2 deletions lib/ml4w.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,33 @@ waybar_is_ml4w_managed() {
return 1
}

# A config path is ML4W-managed when it is a symlink itself, or when it resolves
# into the ML4W dotfiles root. ML4W 2.16 links whole app directories
# (~/.config/rofi -> ~/.mydotfiles/...), so the files inside are regular files from
# the link's point of view while ML4W still owns them. ML4W_DOTFILES_DIR relocates
# the root for non-default installs.
path_is_ml4w_managed() {
local path="$1" real root="${ML4W_DOTFILES_DIR:-${HOME}/.mydotfiles}"
[[ -e "${path}" ]] || return 1
[[ -L "${path}" ]] && return 0
real="$(readlink -f -- "${path}")" || return 1
[[ "${real}" == "${root}/"* ]]
}

# Hyprland colour files (colors.lua / colors.conf) carry Dreamcoder colours when
# they are a symlink into a Dreamcoder variant, or a regular file byte-identical
# to one of the DreamcoderThemes hypr-colors-* variants. Regular files are the
# supported layout: ML4W 2.16 ships them as regular files and the theme sync
# writes through whatever sits at the path (it only flips existing symlinks).
# True when the path is a symlink whose target name carries "dreamcoder".
_is_dreamcoder_symlink() {
[[ -L "$1" && "$(readlink "$1")" == *dreamcoder* ]]
}

hypr_colors_is_dreamcoder() {
local path="$1" variant
[[ -e "${path}" ]] || return 1
[[ -L "${path}" && "$(readlink "${path}")" == *dreamcoder* ]] && return 0
_is_dreamcoder_symlink "${path}" && return 0
for variant in "${DREAMCODER_DOTS_DIR}"/DreamcoderThemes/dreamcoder/hypr-colors-*."${path##*.}"; do
[[ -f "${variant}" ]] && cmp -s "${path}" "${variant}" && return 0
done
Expand All @@ -48,7 +66,7 @@ hypr_colors_is_dreamcoder() {
waybar_colors_is_dreamcoder() {
local path="$1"
[[ -e "${path}" ]] || return 1
[[ -L "${path}" && "$(readlink "${path}")" == *dreamcoder* ]] && return 0
_is_dreamcoder_symlink "${path}" && return 0
[[ -L "${path}" ]] && return 1
head -n 5 "${path}" | grep -q 'Generated by Dreamcoder sync'
}
98 changes: 51 additions & 47 deletions scripts/verify-ml4w-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,24 @@ echo ""
# ── 1. System checks ═════════════════════════════════════════════════════════
title "1. System"

if command -v hyprctl >/dev/null; then
if hyprctl monitors -j 2>/dev/null | jq -e 'length > 0' >/dev/null 2>&1; then
ok "Hyprland is running"
else
fail "Hyprland is not running (no monitors detected)"
fi
else
fail "hyprctl not found — Hyprland not installed?"
fi

have_jq=0
if command -v jq >/dev/null; then
ok "jq is installed"
have_jq=1
else
fail "jq is not installed"
fi

if ! command -v hyprctl >/dev/null; then
fail "hyprctl not found — Hyprland not installed?"
elif ((!have_jq)); then
warn "Hyprland running check skipped (needs jq)"
elif hyprctl monitors -j 2>/dev/null | jq -e 'length > 0' >/dev/null 2>&1; then
ok "Hyprland is running"
else
fail "Hyprland is not running (no monitors detected)"
fi

HYPR_VERSION=""
if command -v hyprctl >/dev/null; then
HYPR_VERSION="$(hyprctl version 2>/dev/null | head -1 | grep -oP 'Hyprland \K[^ ]+' || echo "unknown")"
Expand All @@ -114,11 +116,10 @@ SYMLINKS=(
for entry in "${SYMLINKS[@]}"; do
path="${entry%%:*}"
label="${entry#*:}"
if [[ -L "$path" ]]; then
target=$(readlink "$path")
ok "${label} (→ ${target})"
if path_is_ml4w_managed "$path"; then
ok "${label} (→ $(readlink -f -- "$path"))"
elif [[ -f "$path" ]]; then
warn "${label} — regular file, not symlink"
warn "${label} — regular file outside the ML4W dotfiles"
else
fail "${label} — NOT FOUND"
fi
Expand Down Expand Up @@ -160,29 +161,22 @@ else
fail "waybar/colors.css is missing"
fi

# Wlogout → waybar
if [[ -L "${HOME}/.config/wlogout/colors.css" ]]; then
target=$(readlink "${HOME}/.config/wlogout/colors.css")
if [[ "$target" == *"waybar/colors.css" ]]; then
ok "wlogout/colors.css → waybar (shared)"
else
warn "wlogout/colors.css → ${target}"
fi
else
fail "wlogout/colors.css is not a symlink"
fi

# Swaync → waybar
if [[ -L "${HOME}/.config/swaync/colors.css" ]]; then
target=$(readlink "${HOME}/.config/swaync/colors.css")
if [[ "$target" == *"waybar/colors.css" ]]; then
ok "swaync/colors.css → waybar (shared)"
# Wlogout and Swaync share Waybar's colours through a symlink.
check_shared_waybar_colors() {
local app="$1" link="${HOME}/.config/${1}/colors.css" target
if [[ -L "$link" ]]; then
target=$(readlink "$link")
if [[ "$target" == *"waybar/colors.css" ]]; then
ok "${app}/colors.css → waybar (shared)"
else
warn "${app}/colors.css → ${target}"
fi
else
warn "swaync/colors.css → ${target}"
fail "${app}/colors.css is not a symlink"
fi
else
fail "swaync/colors.css is not a symlink"
fi
}
check_shared_waybar_colors wlogout
check_shared_waybar_colors swaync

# Hyprland colors.lua / colors.conf: a Dreamcoder symlink or a managed regular
# file with Dreamcoder content (ML4W 2.16 ships regular files; the sync writes
Expand Down Expand Up @@ -278,21 +272,22 @@ fi
# ── 7. ML4W profile ═══════════════════════════════════════════════════════
title "7. ML4W profile"

if [[ -n "${PROFILE_NAME}" ]]; then
PROFILE_FILE="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/${PROFILE_NAME}.json"
else
if [[ -z "${PROFILE_NAME}" ]]; then
# Auto-detect
HOSTNAME="$(hostname -s 2>/dev/null || echo "unknown")"
case "$(echo "${HOSTNAME}" | tr '[:upper:]' '[:lower:]')" in
*asus* | *vivobook*) PROFILE_NAME="asus-vivobook15" ;;
*) PROFILE_NAME="default" ;;
esac
PROFILE_FILE="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/${PROFILE_NAME}.json"
info "Auto-detected profile: ${PROFILE_NAME}"
fi
PROFILES_DIR="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder"
PROFILE_FILE="${PROFILES_DIR}/${PROFILE_NAME}.json"

if [[ -f "$PROFILE_FILE" ]]; then
if jq empty "$PROFILE_FILE" 2>/dev/null; then
if ((!have_jq)); then
warn "Profile ${PROFILE_NAME}.json JSON check skipped (needs jq)"
elif jq empty "$PROFILE_FILE" 2>/dev/null; then
ok "Profile ${PROFILE_NAME}.json is valid JSON"
else
fail "Profile ${PROFILE_NAME}.json is INVALID JSON"
Expand All @@ -302,22 +297,31 @@ else
fi

# Schema validation
SCHEMA_FILE="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/profile.schema.json"
SCHEMA_FILE="${PROFILES_DIR}/profile.schema.json"
if [[ -f "$SCHEMA_FILE" ]]; then
ok "Schema file exists"
if command -v python3 >/dev/null && python3 -c "import jsonschema" 2>/dev/null; then
if python3 -c "
import json, sys
with open('${SCHEMA_FILE}') as f: schema = json.load(f)
with open('${PROFILE_FILE}') as f: profile = json.load(f)
# Paths travel through argv: interpolating them into Python source breaks on a
# quote and would let a crafted path run code.
if python3 - "$SCHEMA_FILE" "$PROFILE_FILE" 2>/dev/null <<'PY'
import json
import sys

import jsonschema

with open(sys.argv[1]) as f:
schema = json.load(f)
with open(sys.argv[2]) as f:
profile = json.load(f)
jsonschema.validate(instance=profile, schema=schema)
print('OK')
" 2>/dev/null; then
PY
then
ok "Profile matches schema"
else
warn "Profile does NOT match schema"
fi
else
warn "Schema validation skipped (python3 with jsonschema not available)"
fi
else
warn "Schema file not found"
Expand Down
50 changes: 50 additions & 0 deletions tests/ml4w/ml4w_managed.bats
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,53 @@ load_ml4w_lib() {
run waybar_colors_is_dreamcoder "${HOME}/.config/waybar/colors.css"
[ "$status" -ne 0 ]
}

# ── path_is_ml4w_managed ─────────────────────────────────────────────────────
# ML4W 2.16 links whole app directories (~/.config/rofi -> ~/.mydotfiles/...), so
# the files inside are regular files from the link's point of view while still
# being owned by ML4W. Ownership therefore follows where the path resolves to.

@test "ml4w lib: a file inside a directory symlinked into the ML4W dotfiles is managed" {
mkdir -p "${HOME}/.config" "${HOME}/.mydotfiles/com.ml4w.dotfiles/.config/rofi"
: >"${HOME}/.mydotfiles/com.ml4w.dotfiles/.config/rofi/config.rasi"
ln -s "${HOME}/.mydotfiles/com.ml4w.dotfiles/.config/rofi" "${HOME}/.config/rofi"

load_ml4w_lib
run path_is_ml4w_managed "${HOME}/.config/rofi/config.rasi"
[ "$status" -eq 0 ]
}

@test "ml4w lib: a symlinked file is managed" {
mkdir -p "${HOME}/.config/swaync" "${HOME}/.mydotfiles/x"
: >"${HOME}/.mydotfiles/x/config.json"
ln -s "${HOME}/.mydotfiles/x/config.json" "${HOME}/.config/swaync/config.json"

load_ml4w_lib
run path_is_ml4w_managed "${HOME}/.config/swaync/config.json"
[ "$status" -eq 0 ]
}

@test "ml4w lib: a plain regular file outside the ML4W dotfiles is not managed" {
mkdir -p "${HOME}/.config/wlogout"
: >"${HOME}/.config/wlogout/layout"

load_ml4w_lib
run path_is_ml4w_managed "${HOME}/.config/wlogout/layout"
[ "$status" -ne 0 ]
}

@test "ml4w lib: a missing path is not managed" {
load_ml4w_lib
run path_is_ml4w_managed "${HOME}/.config/rofi/config.rasi"
[ "$status" -ne 0 ]
}

@test "ml4w lib: ML4W_DOTFILES_DIR relocates the dotfiles root" {
mkdir -p "${HOME}/elsewhere/rofi" "${HOME}/.config"
: >"${HOME}/elsewhere/rofi/config.rasi"
ln -s "${HOME}/elsewhere/rofi" "${HOME}/.config/rofi"

load_ml4w_lib
ML4W_DOTFILES_DIR="${HOME}/elsewhere" run path_is_ml4w_managed "${HOME}/.config/rofi/config.rasi"
[ "$status" -eq 0 ]
}
65 changes: 65 additions & 0 deletions tests/ml4w/verify_setup.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ============================================================================
# BATS tests: scripts/verify-ml4w-setup.sh robustness
# ============================================================================
# The script used to interpolate file paths into inline Python source, so a path
# with an apostrophe broke schema validation (and a crafted one could inject
# code); paths now travel through sys.argv. A missing jsonschema also skipped the
# check without saying so.

load '../helpers/setup'

# tests/helpers/setup.bash points HOME at a temp dir, which hides user-site Python
# packages (jsonschema often lives there). Keep the invoking user's package base so the
# schema check really runs instead of being skipped.
use_real_python_userbase() {
local real_home
real_home="$(getent passwd "$(id -u)" | cut -d: -f6)"
export PYTHONUSERBASE="${real_home}/.local"
}

# A throwaway dots tree whose directory name contains an apostrophe.
make_dots_tree() {
DOTS="${TEST_TEMP_HOME}/dots'tree"
mkdir -p "${DOTS}/lib" "${DOTS}/DreamcoderProfiles/dreamcoder"
cp "${DREAMCODER_DOTS_DIR}/lib/"*.sh "${DOTS}/lib/"
cp "${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/default.json" \
"${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/profile.schema.json" \
"${DOTS}/DreamcoderProfiles/dreamcoder/"
}

run_verify() {
DREAMCODER_DOTS_DIR="${DOTS}" run bash "${BATS_TEST_DIRNAME}/../../scripts/verify-ml4w-setup.sh" \
--profile default
}

@test "verify-ml4w-setup: schema validation works when the dots path contains an apostrophe" {
use_real_python_userbase
python3 -c 'import jsonschema' 2>/dev/null || skip "python jsonschema not installed"
command -v jq >/dev/null || skip "jq not installed"
make_dots_tree
run_verify
[[ "$output" == *"Profile matches schema"* ]]
}

@test "verify-ml4w-setup: says so when schema validation is skipped for lack of jsonschema" {
make_dots_tree
# A python3 without jsonschema: a shim earlier in PATH.
mkdir -p "${TEST_TEMP_HOME}/bin"
printf '#!/bin/sh\nexit 1\n' >"${TEST_TEMP_HOME}/bin/python3"
chmod +x "${TEST_TEMP_HOME}/bin/python3"
PATH="${TEST_TEMP_HOME}/bin:${PATH}" run_verify
[[ "$output" == *"Schema validation skipped"* ]]
}

@test "verify-ml4w-setup: a missing jq is reported as skipped, not as invalid JSON" {
make_dots_tree
# PATH with every tool except jq: symlink the basics into an empty bin dir.
mkdir -p "${TEST_TEMP_HOME}/nojq"
for tool in bash env dirname basename cat grep sed awk tr cut head tail sort uniq date git python3 readlink; do
src="$(command -v "$tool" 2>/dev/null)" && ln -sf "$src" "${TEST_TEMP_HOME}/nojq/$tool"
done
PATH="${TEST_TEMP_HOME}/nojq" run_verify
[[ "$output" == *"jq is not installed"* ]]
[[ "$output" == *"JSON check skipped (needs jq)"* ]]
[[ "$output" != *"INVALID JSON"* ]]
}
Loading