mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +01:00
Remove aur-publish and dopamine-ux modules
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Audit found no external consumers. Drop their refs in md_naming migrate tooling and the pre-commit comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYFhDShgeHByh6yT9L
This commit is contained in:
parent
fe4e34287a
commit
5a87868763
@ -13,7 +13,7 @@
|
|||||||
# linters, and carry this same hook so committing with one of those configs is
|
# linters, and carry this same hook so committing with one of those configs is
|
||||||
# gated too. This root config is what `pre-commit install` wires up, and it is
|
# gated too. This root config is what `pre-commit install` wires up, and it is
|
||||||
# the only one that covers the subprojects without a config of their own
|
# the only one that covers the subprojects without a config of their own
|
||||||
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
|
# (guard-lib/, crdt_sync_dart/, file_length/, ...).
|
||||||
#
|
#
|
||||||
# The path is repo-relative on purpose. pre-commit always runs hooks from the
|
# The path is repo-relative on purpose. pre-commit always runs hooks from the
|
||||||
# git root, so this resolves the same locally and on a CI runner -- an absolute
|
# git root, so this resolves the same locally and on a CI runner -- an absolute
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
# aur-publish
|
|
||||||
|
|
||||||
Hands-off publishing of kuhy's Flutter apps to the AUR.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/src/utils/aur-publish/publish.sh # wait for registration, then publish
|
|
||||||
~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
|
|
||||||
~/src/utils/aur-publish/publish.sh --skip-wait # account already works
|
|
||||||
~/src/utils/aur-publish/publish.sh --only habit-stack
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
1. Polls `/register` until AUR signup reopens, opens it in a browser and
|
|
||||||
prints the SSH key to paste. **Signup is the only manual step** — it
|
|
||||||
requires email confirmation.
|
|
||||||
2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
|
|
||||||
3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
|
|
||||||
`pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
|
|
||||||
`pacman -U` → launch check → `.SRCINFO` → commit → push.
|
|
||||||
|
|
||||||
Every package is built, installed and **launched** before it is pushed, so a
|
|
||||||
broken package cannot reach the AUR. A package failing any gate is skipped and
|
|
||||||
reported; the others still publish. Safe to re-run.
|
|
||||||
|
|
||||||
## anubis_gate.py
|
|
||||||
|
|
||||||
The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
|
|
||||||
requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
|
|
||||||
`curl` therefore reports 200 for a page that is actually unreachable — which is
|
|
||||||
exactly how "is registration back?" gets answered wrong. This module solves the
|
|
||||||
challenge and reports the status of the *real* page behind it.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 anubis_gate.py /register # prints the true status code
|
|
||||||
```
|
|
||||||
|
|
||||||
## AUR rules enforced before pushing
|
|
||||||
|
|
||||||
The AUR does not review uploads, but per the submission guidelines "packages
|
|
||||||
that violate the rules may be deleted without warning". `check_aur_rules()`
|
|
||||||
blocks a push on the mechanically checkable ones: `# Maintainer:` line,
|
|
||||||
`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
|
|
||||||
not already in an official repo, `pkgdesc`/`url`/`license` present, and
|
|
||||||
`.SRCINFO` matching the PKGBUILD.
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
|
|
||||||
|
|
||||||
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
|
|
||||||
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
|
|
||||||
`curl` therefore reports 200 for a page that is actually unreachable, which is
|
|
||||||
exactly how "is /register back up?" gets answered wrong.
|
|
||||||
|
|
||||||
This module solves the challenge and reports the status of the *real* page
|
|
||||||
behind it, so the caller can distinguish:
|
|
||||||
|
|
||||||
* 200 -> genuinely available
|
|
||||||
* 503 -> reached the site; that endpoint is disabled server-side
|
|
||||||
* other-> whatever the app actually said
|
|
||||||
|
|
||||||
Exit codes mirror the HTTP status so shell callers can branch on them:
|
|
||||||
0 for 200, 1 for anything else, 2 for a local/transport failure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import http.cookiejar
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
BASE = "https://aur.archlinux.org"
|
|
||||||
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
|
||||||
CHALLENGE_RE = re.compile(
|
|
||||||
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
|
|
||||||
re.S,
|
|
||||||
)
|
|
||||||
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
|
||||||
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
|
|
||||||
# difficulty spike, so the poller degrades instead of spinning forever.
|
|
||||||
MAX_NONCE = 50_000_000
|
|
||||||
|
|
||||||
|
|
||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
||||||
"""Keep redirects visible instead of silently following them."""
|
|
||||||
|
|
||||||
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _opener() -> urllib.request.OpenerDirector:
|
|
||||||
jar = http.cookiejar.CookieJar()
|
|
||||||
opener = urllib.request.build_opener(
|
|
||||||
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
|
|
||||||
)
|
|
||||||
opener.addheaders = [
|
|
||||||
("User-Agent", UA),
|
|
||||||
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
|
|
||||||
]
|
|
||||||
return opener
|
|
||||||
|
|
||||||
|
|
||||||
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
|
|
||||||
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
|
|
||||||
prefix = "0" * difficulty
|
|
||||||
started = time.time()
|
|
||||||
for nonce in range(MAX_NONCE):
|
|
||||||
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
|
|
||||||
if digest.startswith(prefix):
|
|
||||||
return nonce, digest, time.time() - started
|
|
||||||
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
|
|
||||||
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
|
|
||||||
opener = _opener()
|
|
||||||
url = f"{BASE}{path}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with opener.open(url, timeout=timeout) as resp:
|
|
||||||
body = resp.read().decode("utf-8", "replace")
|
|
||||||
status = resp.status
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace")
|
|
||||||
|
|
||||||
match = CHALLENGE_RE.search(body)
|
|
||||||
if not match:
|
|
||||||
return status, body
|
|
||||||
|
|
||||||
challenge = json.loads(match.group(1))["challenge"]
|
|
||||||
nonce, digest, elapsed = _solve(
|
|
||||||
challenge["randomData"], int(challenge["difficulty"])
|
|
||||||
)
|
|
||||||
query = urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"id": challenge["id"],
|
|
||||||
"response": digest,
|
|
||||||
"nonce": str(nonce),
|
|
||||||
"redir": path,
|
|
||||||
"elapsedTime": str(int(elapsed * 1000)),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
|
|
||||||
except urllib.error.HTTPError:
|
|
||||||
# A redirect or non-2xx here is fine; what matters is the cookie the
|
|
||||||
# opener's jar just captured.
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
with opener.open(url, timeout=timeout) as resp:
|
|
||||||
return resp.status, resp.read().decode("utf-8", "replace")
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
return exc.code, exc.read().decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("path", help="AUR path, e.g. /register")
|
|
||||||
parser.add_argument(
|
|
||||||
"--print-body", action="store_true", help="write the page body to stdout"
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, body = fetch(args.path)
|
|
||||||
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
|
|
||||||
print(f"transport error: {exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
print(status)
|
|
||||||
if args.print_body:
|
|
||||||
sys.stdout.write(body)
|
|
||||||
return 0 if status == 200 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Building, verifying and pushing one package to the AUR.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
publish_one() {
|
|
||||||
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
|
|
||||||
local dir="$AUR_ROOT/$pkg"
|
|
||||||
local version
|
|
||||||
|
|
||||||
log "$pkg"
|
|
||||||
|
|
||||||
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
|
|
||||||
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
|
|
||||||
|
|
||||||
version="$(newest_tag "$repo")"
|
|
||||||
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
|
|
||||||
info "newest release tag: v$version"
|
|
||||||
|
|
||||||
cd "$dir"
|
|
||||||
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
|
|
||||||
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
|
|
||||||
# computed after the tag exists — never committed ahead of time.
|
|
||||||
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
|
|
||||||
|
|
||||||
info "building"
|
|
||||||
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
|
|
||||||
warn "$pkg: makepkg failed"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local built
|
|
||||||
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
|
|
||||||
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
|
|
||||||
|
|
||||||
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
|
|
||||||
# for /opt-installed apps, so only genuinely new errors are worth failing on.
|
|
||||||
local findings
|
|
||||||
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
|
|
||||||
if [[ -n "$findings" ]]; then
|
|
||||||
warn "$pkg: namcap errors:"
|
|
||||||
printf ' %s\n' "$findings"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "installing and launching"
|
|
||||||
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|
|
||||||
|| { warn "$pkg: pacman -U failed"; return 1; }
|
|
||||||
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
|
|
||||||
|
|
||||||
# Generated before the rule check, which verifies it matches the PKGBUILD.
|
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
|
||||||
check_aur_rules "$pkg" || return 1
|
|
||||||
|
|
||||||
# The AUR repo is a separate remote living alongside the build dir; adding
|
|
||||||
# it here (rather than cloning into a fresh path) keeps the verified
|
|
||||||
# PKGBUILD and the thing being pushed byte-identical.
|
|
||||||
if [[ ! -d .git ]]; then
|
|
||||||
git init -q -b master .
|
|
||||||
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
|
||||||
fi
|
|
||||||
git remote get-url origin >/dev/null 2>&1 \
|
|
||||||
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
|
|
||||||
|
|
||||||
# Fetch first: an existing AUR package must be built on, not overwritten.
|
|
||||||
if git fetch -q origin master 2>/dev/null; then
|
|
||||||
git reset -q --soft FETCH_HEAD 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > .gitignore <<'EOF'
|
|
||||||
# Build products; the AUR tracks only the recipe.
|
|
||||||
*.pkg.tar.zst
|
|
||||||
*.tar.gz
|
|
||||||
src/
|
|
||||||
pkg/
|
|
||||||
EOF
|
|
||||||
|
|
||||||
git add PKGBUILD .SRCINFO .gitignore
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
info "already up to date on the AUR; nothing to push"
|
|
||||||
PUBLISHED+=("$pkg v$version (unchanged)")
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
git -c user.name='Krzysztof Rudnicki' \
|
|
||||||
-c user.email='krzysztofrudnicki0@gmail.com' \
|
|
||||||
commit -q -m "$pkg $version-1"
|
|
||||||
|
|
||||||
if ((DRY_RUN)); then
|
|
||||||
info "DRY RUN — not pushing"
|
|
||||||
PUBLISHED+=("$pkg v$version (dry run)")
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "pushing to the AUR"
|
|
||||||
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
|
|
||||||
PUBLISHED+=("$pkg v$version")
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Waiting for AUR registration and SSH access to come up.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
register_status() {
|
|
||||||
python3 "$GATE" /register 2>/dev/null | head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
open_browser() {
|
|
||||||
local url="$1"
|
|
||||||
local browser
|
|
||||||
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
|
|
||||||
if command -v "$browser" >/dev/null 2>&1; then
|
|
||||||
"$browser" "$url" >/dev/null 2>&1 &
|
|
||||||
info "Opened $url in $browser"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
warn "No browser found; open this yourself: $url"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_key() {
|
|
||||||
if [[ ! -f "$SSH_KEY" ]]; then
|
|
||||||
log "Generating an AUR SSH key (none at $SSH_KEY)"
|
|
||||||
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
|
|
||||||
fi
|
|
||||||
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
|
|
||||||
log "Adding $AUR_HOST to ~/.ssh/config"
|
|
||||||
mkdir -p "$HOME/.ssh"
|
|
||||||
cat >> "$HOME/.ssh/config" <<EOF
|
|
||||||
|
|
||||||
# AUR uses a dedicated key so it stays separate from the GitHub identity.
|
|
||||||
Host $AUR_HOST
|
|
||||||
User aur
|
|
||||||
IdentityFile $SSH_KEY
|
|
||||||
IdentitiesOnly yes
|
|
||||||
EOF
|
|
||||||
chmod 600 "$HOME/.ssh/config"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ssh_works() {
|
|
||||||
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
|
|
||||||
# successful key auth is the success signal, not an error.
|
|
||||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
|
||||||
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
|
|
||||||
grep -qiE 'interactive shell is disabled|Welcome to AUR'
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_registration() {
|
|
||||||
local status
|
|
||||||
status="$(register_status)"
|
|
||||||
if [[ "$status" == "200" ]]; then
|
|
||||||
log "AUR registration is OPEN"
|
|
||||||
else
|
|
||||||
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
|
|
||||||
info "Ctrl-C to stop; re-run any time, nothing is lost."
|
|
||||||
while :; do
|
|
||||||
sleep "$POLL_SECONDS"
|
|
||||||
status="$(register_status)"
|
|
||||||
if [[ "$status" == "200" ]]; then
|
|
||||||
log "Registration is BACK (HTTP 200)"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
open_browser "https://$AUR_HOST/register"
|
|
||||||
cat <<EOF
|
|
||||||
|
|
||||||
------------------------------------------------------------------
|
|
||||||
Sign up, and paste this into the "SSH Public Key" field:
|
|
||||||
|
|
||||||
$(sed 's/^/ /' "$SSH_KEY.pub")
|
|
||||||
|
|
||||||
You will need to confirm the address from your email before the
|
|
||||||
account works. This script waits for that automatically.
|
|
||||||
------------------------------------------------------------------
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_ssh() {
|
|
||||||
log "Waiting for the SSH key to authenticate against the AUR"
|
|
||||||
local waited=0
|
|
||||||
until ssh_works; do
|
|
||||||
sleep 30
|
|
||||||
waited=$((waited + 30))
|
|
||||||
if ((waited % 300 == 0)); then
|
|
||||||
info "still waiting ($((waited / 60)) min) — key not accepted yet"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
log "SSH authentication OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Phase 2: build, verify and publish one package
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Gates a package must pass before it is allowed near the AUR.
|
|
||||||
#
|
|
||||||
# Sourced by publish.sh, never executed: these functions read the readonly
|
|
||||||
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
|
|
||||||
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
newest_tag() {
|
|
||||||
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
|
|
||||||
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
|
|
||||||
git -C "$1" ls-remote --tags origin 2>/dev/null |
|
|
||||||
awk '{print $2}' |
|
|
||||||
sed 's|refs/tags/||' |
|
|
||||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
|
|
||||||
sed 's/^v//' |
|
|
||||||
sort -V |
|
|
||||||
tail -1
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_installed() {
|
|
||||||
local binary="$1" expected="$2" wrapper="$3" output
|
|
||||||
|
|
||||||
# An instance left over from a previous run would make the wrapper print
|
|
||||||
# "already running" and hand off, which passes trivially and turns the
|
|
||||||
# strongest gate in this script into the weakest. Clear it first so the
|
|
||||||
# check always exercises a real cold start.
|
|
||||||
#
|
|
||||||
# Resolved through /proc/<pid>/exe rather than any form of name or
|
|
||||||
# command-line matching. Every argv-based approach selects the wrong
|
|
||||||
# processes here:
|
|
||||||
# * `pkill -x` compares against the kernel's 15-character comm field, and
|
|
||||||
# two of these wrappers are longer (habit_stack_desktop is 19), so a
|
|
||||||
# name match would silently never fire.
|
|
||||||
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
|
|
||||||
# any process whose command line merely *mentions* the path — this
|
|
||||||
# script, the shell running it, and the matching tool itself all
|
|
||||||
# qualify, so the run kills its own shell instead of the app. A
|
|
||||||
# $$/$PPID guard does not cover it, because the offending shell is
|
|
||||||
# often a more distant ancestor.
|
|
||||||
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
|
|
||||||
# being executed, so it cannot collide with an incidental mention.
|
|
||||||
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
|
|
||||||
# a glob instead of threading the package name through another parameter.
|
|
||||||
#
|
|
||||||
# The link is read without `-f`: it is already fully resolved, and by the
|
|
||||||
# time this runs `pacman -U` has replaced the binary, so a leftover process
|
|
||||||
# points at an unlinked inode that the kernel reports with a " (deleted)"
|
|
||||||
# suffix. That suffix has to be stripped or the match silently misses the
|
|
||||||
# one process this exists to kill.
|
|
||||||
local pid exe killed=()
|
|
||||||
for pid in /proc/[0-9]*; do
|
|
||||||
pid="${pid#/proc/}"
|
|
||||||
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
|
|
||||||
exe="${exe% (deleted)}"
|
|
||||||
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
|
|
||||||
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
|
|
||||||
done
|
|
||||||
if ((${#killed[@]})); then
|
|
||||||
info "stopped a running instance so the launch check is meaningful"
|
|
||||||
# `kill` only reports that the signal was sent. Waiting for the process
|
|
||||||
# to actually go keeps a slow exit from failing the cold start below on
|
|
||||||
# a port that is still bound.
|
|
||||||
local waited=0
|
|
||||||
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
|
|
||||||
sleep 0.1
|
|
||||||
((waited++))
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
|
|
||||||
if grep -qF "$expected" <<<"$output"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
warn "unexpected startup output: $output"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# The AUR does not review uploads, but "packages that violate the rules may be
|
|
||||||
# deleted without warning" (AUR submission guidelines). These are the rules that
|
|
||||||
# are mechanically checkable, enforced here so a violation blocks the push
|
|
||||||
# rather than surfacing later as a deletion request.
|
|
||||||
check_aur_rules() {
|
|
||||||
local pkg="$1" problems=()
|
|
||||||
|
|
||||||
grep -q '^# Maintainer:' PKGBUILD \
|
|
||||||
|| problems+=("missing '# Maintainer:' comment on line 1")
|
|
||||||
|
|
||||||
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|
|
||||||
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
|
|
||||||
|
|
||||||
# `replaces` makes pacman rip out the named package on the next -Sy, which
|
|
||||||
# is only correct for a genuine rename. `conflicts` is the right tool.
|
|
||||||
grep -q '^replaces=' PKGBUILD \
|
|
||||||
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
|
|
||||||
|
|
||||||
# A package already in an official repo must never be duplicated.
|
|
||||||
if pacman -Si "$pkg" >/dev/null 2>&1; then
|
|
||||||
problems+=("$pkg exists in an official repo — must not be submitted")
|
|
||||||
fi
|
|
||||||
|
|
||||||
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|
|
||||||
|| problems+=("missing pkgdesc/url/license")
|
|
||||||
|
|
||||||
# A stale .SRCINFO is the single most common upload rejection.
|
|
||||||
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
|
|
||||||
problems+=(".SRCINFO does not match PKGBUILD")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ((${#problems[@]})); then
|
|
||||||
warn "$pkg: AUR rule violations:"
|
|
||||||
printf ' - %s\n' "${problems[@]}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
info "AUR rule checks passed"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Publish kuhy's three Flutter apps to the AUR, hands-off.
|
|
||||||
#
|
|
||||||
# Run this once AUR registration is back. It:
|
|
||||||
# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
|
|
||||||
# then opens it in a browser and prints the SSH key to paste.
|
|
||||||
# (Signup is the ONLY manual step: it needs an email confirmation, and
|
|
||||||
# creating accounts is not something this script will do for you.)
|
|
||||||
# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
|
|
||||||
# 3. From there, fully unattended, for each package:
|
|
||||||
# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
|
|
||||||
# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
|
|
||||||
# -> commit -> push.
|
|
||||||
#
|
|
||||||
# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
|
|
||||||
# broken package cannot reach the AUR. A package that fails any gate is
|
|
||||||
# skipped and reported; the others still publish.
|
|
||||||
#
|
|
||||||
# Idempotent and resumable: re-running skips work that is already done, and
|
|
||||||
# an already-published package is updated rather than duplicated.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
readonly SCRIPT_DIR
|
|
||||||
readonly GATE="$SCRIPT_DIR/anubis_gate.py"
|
|
||||||
readonly AUR_HOST="aur.archlinux.org"
|
|
||||||
readonly SSH_KEY="$HOME/.ssh/aur"
|
|
||||||
readonly AUR_ROOT="$HOME/sdk/aur"
|
|
||||||
|
|
||||||
# package:repo:wrapper-process:cli-name:expected-startup-string
|
|
||||||
# The wrapper process name is NOT derivable from the package name
|
|
||||||
# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
|
|
||||||
readonly PACKAGES=(
|
|
||||||
"todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
|
|
||||||
"habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
|
|
||||||
"diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
|
|
||||||
)
|
|
||||||
|
|
||||||
POLL_SECONDS=300
|
|
||||||
SKIP_WAIT=0
|
|
||||||
DRY_RUN=0
|
|
||||||
ONLY=""
|
|
||||||
FAILED=()
|
|
||||||
PUBLISHED=()
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--skip-wait Assume the account + SSH key already work; go straight
|
|
||||||
to building and publishing.
|
|
||||||
--dry-run Do everything except the final 'git push' to the AUR.
|
|
||||||
--only NAME Publish just one package (todo-flutter, habit-stack,
|
|
||||||
diet-guard-app).
|
|
||||||
--poll SECONDS How often to re-check /register (default: $POLL_SECONDS).
|
|
||||||
-h, --help Show this help.
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
|
|
||||||
info() { printf ' %s\n' "$*"; }
|
|
||||||
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
|
|
||||||
die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
require_tools() {
|
|
||||||
local missing=()
|
|
||||||
local tool
|
|
||||||
for tool in git makepkg updpkgsums namcap ssh python3 curl; do
|
|
||||||
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
|
|
||||||
done
|
|
||||||
if ((${#missing[@]})); then
|
|
||||||
info "Installing missing tools: ${missing[*]}"
|
|
||||||
# namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
|
|
||||||
sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
|
|
||||||
|| die "could not install: ${missing[*]}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Phase 1: wait for registration, then for the key to work
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# The rest lives in lib/, sourced (not executed) so every function sees the
|
|
||||||
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
|
|
||||||
# shellcheck source=lib/aur_registration.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_registration.sh"
|
|
||||||
# shellcheck source=lib/aur_verify.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_verify.sh"
|
|
||||||
# shellcheck source=lib/aur_publish_one.sh
|
|
||||||
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
|
|
||||||
|
|
||||||
main() {
|
|
||||||
require_tools
|
|
||||||
[[ -f "$GATE" ]] || die "missing $GATE"
|
|
||||||
mkdir -p "$AUR_ROOT"
|
|
||||||
|
|
||||||
ensure_key
|
|
||||||
if ((SKIP_WAIT)); then
|
|
||||||
ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
|
|
||||||
else
|
|
||||||
wait_for_registration
|
|
||||||
wait_for_ssh
|
|
||||||
fi
|
|
||||||
|
|
||||||
local entry pkg repo wrapper binary expected
|
|
||||||
for entry in "${PACKAGES[@]}"; do
|
|
||||||
IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
|
|
||||||
[[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
|
|
||||||
if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
|
|
||||||
FAILED+=("$pkg")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
log "Summary"
|
|
||||||
if ((${#PUBLISHED[@]})); then
|
|
||||||
printf ' published: %s\n' "${PUBLISHED[@]}"
|
|
||||||
fi
|
|
||||||
if ((${#FAILED[@]})); then
|
|
||||||
printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
|
|
||||||
info "Nothing broken was pushed; fix and re-run (safe to repeat)."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if ((${#PUBLISHED[@]} == 0)); then
|
|
||||||
warn "nothing to do"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--skip-wait) SKIP_WAIT=1; shift ;;
|
|
||||||
--dry-run) DRY_RUN=1; shift ;;
|
|
||||||
--only) ONLY="$2"; shift 2 ;;
|
|
||||||
--poll) POLL_SECONDS="$2"; shift 2 ;;
|
|
||||||
-h|--help) usage ;;
|
|
||||||
*) die "Unknown option: $1" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
# Dopamine UX — session prompts
|
|
||||||
|
|
||||||
Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
|
|
||||||
Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
|
|
||||||
|
|
||||||
Paste **one file's contents** into a fresh Claude session. Each is
|
|
||||||
self-contained: it restates the findings it depends on, so no session needs the
|
|
||||||
context of another.
|
|
||||||
|
|
||||||
## The two tests — every change must pass both
|
|
||||||
|
|
||||||
The article's four mechanics (variable rewards, micro-animations, sound design,
|
|
||||||
near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
|
|
||||||
two questions, not by an allow/deny list:
|
|
||||||
|
|
||||||
1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
|
|
||||||
you are actually at 93%. A streak is legitimate iff it counts real days.
|
|
||||||
Dopamine may come from framing, timing and salience — **never from a
|
|
||||||
fabricated number**.
|
|
||||||
2. **Is the variance real?** A random pick from a real pool (next workout, next
|
|
||||||
task) is legitimate. Injected random bonus points to manufacture uncertainty
|
|
||||||
are not.
|
|
||||||
|
|
||||||
### The load-bearing corollary
|
|
||||||
|
|
||||||
**Reward the recording, never the number recorded.** In diet-guard the
|
|
||||||
satisfying moment fires on *logging a meal*, not on hitting a calorie target —
|
|
||||||
otherwise the app rewards eating less rather than recording honestly, and
|
|
||||||
corrupts its own data. Same shape in workout_app (reward logging a workout, not
|
|
||||||
beating a PB) and todo (reward capture, not completion count).
|
|
||||||
|
|
||||||
## Running order
|
|
||||||
|
|
||||||
`01` gates everything. `07` gates `08`. Everything else is independent.
|
|
||||||
|
|
||||||
| # | File | Repo | Findings |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
|
|
||||||
| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
|
|
||||||
| 03 | todo note `85ca329b-9809-412c-a47f-eddf055322cf` | `~/src/diet-guard` | 4 |
|
|
||||||
| 04 | todo note `73a20837-6480-4b31-b966-1898a4e7272e` | `~/src/screen-locker` | 3 |
|
|
||||||
| 05 | todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
|
|
||||||
| 06 | `06-todo.md` | `~/src/todo` | 3 |
|
|
||||||
| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
|
|
||||||
| 08 | todo note `e301e090-b2ee-4e26-8387-00f356b63521` | `~/src/dufs-cloud` | 4 |
|
|
||||||
| 09 | todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | `~/src/wake-alarm` | 2 |
|
|
||||||
|
|
||||||
**27 ranked findings total.**
|
|
||||||
|
|
||||||
### Dependency graph
|
|
||||||
|
|
||||||
```
|
|
||||||
01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
|
|
||||||
├─→ 06 (bump design_system-v0.2.0)
|
|
||||||
└─→ 07 ─→ 08 (bump web_ui-v0.3.2)
|
|
||||||
02-structural-check (independent; run after 01 so it has motion tokens to check)
|
|
||||||
```
|
|
||||||
|
|
||||||
## The top three findings fleet-wide
|
|
||||||
|
|
||||||
Ranked by value-over-effort from the survey data:
|
|
||||||
|
|
||||||
1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
|
|
||||||
populated and no renderer reads it. Zero new state, zero conftest change,
|
|
||||||
zero new dependency.
|
|
||||||
2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
|
|
||||||
Dependency-free via `HapticFeedback` from `flutter/services.dart`.
|
|
||||||
3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
|
|
||||||
the `assets:` block and the VIBRATE permission are **already present**. Only
|
|
||||||
the call is missing.
|
|
||||||
|
|
||||||
## Distributed copies, and the completion protocol
|
|
||||||
|
|
||||||
Each prompt is **also copied into the repo it acts on**, at
|
|
||||||
`<repo>/prompts/dopamine-ux-*.md`, so you can open a repo and say
|
|
||||||
"do dopamine-ux-<name>" without going hunting. **This directory stays the source
|
|
||||||
of truth** — if a prompt needs correcting, fix it here and re-distribute.
|
|
||||||
|
|
||||||
| Prompt | Distributed to |
|
|
||||||
|---|---|
|
|
||||||
| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
|
|
||||||
| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
|
|
||||||
| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
|
|
||||||
| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` — deleted (done) |
|
|
||||||
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
|
|
||||||
| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
|
|
||||||
| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
|
|
||||||
| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
|
|
||||||
| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
|
|
||||||
|
|
||||||
When a session finishes a prompt it **deletes its repo copy** (in the same
|
|
||||||
commit as the implementation) and **appends a row below**. A finished prompt left
|
|
||||||
in a repo is indistinguishable from a pending one, and the next session re-runs
|
|
||||||
it. Partial completion is never recorded as DONE — the file is edited to state
|
|
||||||
what remains instead.
|
|
||||||
|
|
||||||
### Completion log
|
|
||||||
|
|
||||||
| Prompt | Status | Impl commit | Note |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
|
|
||||||
| 02-structural-check.md | not started | — | — |
|
|
||||||
| todo note `85ca329b-9809-412c-a47f-eddf055322cf` | not started | — | — |
|
|
||||||
| todo note `73a20837-6480-4b31-b966-1898a4e7272e` | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
|
|
||||||
| todo note `e50d6a03-ae7d-4da5-bbd3-c33b17f6f074` | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
|
|
||||||
| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
|
|
||||||
| 07-dufs-theme.md | not started | — | — |
|
|
||||||
| todo note `e301e090-b2ee-4e26-8387-00f356b63521` | not started | — | — |
|
|
||||||
| todo note `9e1e4922-9f29-4bad-bcf4-96826aa8ffa4` | not started | — | — |
|
|
||||||
|
|
||||||
## Keeping these prompts honest
|
|
||||||
|
|
||||||
Every path and symbol cited across these files is machine-checked:
|
|
||||||
|
|
||||||
```
|
|
||||||
python3 ~/src/utils/dopamine-ux/verify_prompts.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit 0 means every cited path resolves and every cited symbol is still greppable
|
|
||||||
in the file it is attributed to. **Re-run it after any refactor of the surveyed
|
|
||||||
repos** — a prompt pointing at a renamed function is worse than no prompt, and a
|
|
||||||
path check alone cannot catch that (which is why it greps symbols too).
|
|
||||||
|
|
||||||
Verified 2026-08-16: 146 paths, 30 symbols, all passing.
|
|
||||||
|
|
||||||
## Rules that apply to every prompt
|
|
||||||
|
|
||||||
1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
|
|
||||||
`design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
|
|
||||||
`@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
|
|
||||||
nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
|
|
||||||
`design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
|
|
||||||
bumping to those literal refs.
|
|
||||||
2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
|
|
||||||
"as of 2026-08-16" and will drift. The symbol name is the real anchor —
|
|
||||||
grep for it rather than trusting the line.
|
|
||||||
3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
|
|
||||||
`@media (prefers-reduced-motion: reduce)`. Flutter:
|
|
||||||
`MediaQuery.of(context).disableAnimations`. The OS motion preference is
|
|
||||||
honoured automatically; the sound opt-out is a separate user-facing toggle.
|
|
||||||
4. **Motion and haptics first, sound second.** Haptics need no dependency;
|
|
||||||
sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
|
|
||||||
prompt stages them separately so a short session still lands the valuable
|
|
||||||
half, and sound stays independently revertable.
|
|
||||||
5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
|
|
||||||
6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
|
|
||||||
|
|
||||||
## Source
|
|
||||||
|
|
||||||
Article findings, verbatim from the piece:
|
|
||||||
|
|
||||||
- **Variable reward schedules** — unpredictable outcomes produce stronger
|
|
||||||
dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
|
|
||||||
pull-to-refresh.
|
|
||||||
- **Micro-animations** — "a button that bounces on press, a like counter that
|
|
||||||
ticks upward with a slight delay". Replaces latency with feedback, so waiting
|
|
||||||
reads as responsiveness rather than frustration.
|
|
||||||
- **Sound design** — "operates below the threshold of conscious attention";
|
|
||||||
the article's judgement is that it is more powerful than visual design and
|
|
||||||
gets less critical attention.
|
|
||||||
- **Near-miss effects** — a fitness ring at ninety-three per cent creates
|
|
||||||
discomfort that drives re-engagement, and works *even when the user is
|
|
||||||
explicitly told the mechanism*.
|
|
||||||
- Core claim: **dopamine is released in anticipation of a reward, not on
|
|
||||||
receipt. The uncertainty is the trigger.**
|
|
||||||
@ -1,209 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Verify every path and symbol cited in the dopamine-ux prompt files.
|
|
||||||
|
|
||||||
A copy-pasteable prompt that points at a file which does not exist is worse than
|
|
||||||
no prompt, so this gate adjudicates with an exit code rather than by eye.
|
|
||||||
|
|
||||||
Two checks:
|
|
||||||
1. Every cited on-disk path resolves.
|
|
||||||
2. Every cited symbol is greppable in the file it is attributed to. A path
|
|
||||||
check alone cannot catch a stale line number; a symbol grep can.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
|
|
||||||
HOME = Path.home()
|
|
||||||
|
|
||||||
PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
|
|
||||||
|
|
||||||
# Not on-disk paths despite matching the shape above.
|
|
||||||
SKIP_SUBSTRINGS = (
|
|
||||||
"://",
|
|
||||||
"$",
|
|
||||||
"*",
|
|
||||||
"<",
|
|
||||||
">",
|
|
||||||
"…",
|
|
||||||
"devices/", # Firebase/crdt key path
|
|
||||||
"settings/advanced", # Firebase key path
|
|
||||||
"android.permission",
|
|
||||||
"android.intent",
|
|
||||||
"/sdcard/",
|
|
||||||
"/etc/",
|
|
||||||
"/dev/input/",
|
|
||||||
"/opt/",
|
|
||||||
"com.kuhy",
|
|
||||||
"app-release.apk",
|
|
||||||
"build/app/outputs", # build outputs
|
|
||||||
"flutter/services.dart",
|
|
||||||
"flutter/material.dart", # package imports
|
|
||||||
)
|
|
||||||
|
|
||||||
# Files a prompt CREATES. Citing them forward is correct, so absence is expected
|
|
||||||
# -- but pairing each with its creating prompt still catches a typo.
|
|
||||||
CREATED_BY_PROMPT = {
|
|
||||||
"~/src/utils/unified-design-system/motion.md": "01",
|
|
||||||
"~/src/utils/unified-design-system/scripts/structural_check.py": "02",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cited precisely to state that they do NOT exist.
|
|
||||||
ASSERTED_ABSENT = {
|
|
||||||
"~/gatelock",
|
|
||||||
# Distributed prompt copies are `git rm`d on completion (see the protocol in
|
|
||||||
# 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
|
|
||||||
# table. Asserting ABSENT rather than dropping the row keeps the check
|
|
||||||
# two-directional: a finished prompt reappearing in a repo is itself a
|
|
||||||
# failure, because the next session would re-run it. Move a row here as each
|
|
||||||
# prompt completes.
|
|
||||||
"~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generic prose fragments rather than citations of one specific file.
|
|
||||||
PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
|
|
||||||
|
|
||||||
# Symbols each prompt claims exist, as (repo-relative file, symbol).
|
|
||||||
SYMBOL_CLAIMS = [
|
|
||||||
("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
|
|
||||||
("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
|
|
||||||
("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
|
|
||||||
("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
|
|
||||||
("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
|
|
||||||
("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
|
|
||||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
|
|
||||||
("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
|
|
||||||
("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
|
|
||||||
("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
|
|
||||||
("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
|
|
||||||
("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
|
||||||
"workout_screen_session.dart",
|
|
||||||
"_playBreakEndCue",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
|
|
||||||
"workout_screen_finish.dart",
|
|
||||||
"_persistFinishedWorkout",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
|
||||||
"workout_summary_dialog.dart",
|
|
||||||
"WorkoutSummaryDialog",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
|
|
||||||
"storage_service.dart",
|
|
||||||
"_getSetting",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
|
|
||||||
"exercise_tile_rows.dart",
|
|
||||||
"AnimatedContainer",
|
|
||||||
),
|
|
||||||
("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
|
|
||||||
("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
|
|
||||||
("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
|
|
||||||
("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
|
|
||||||
("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
|
|
||||||
("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
|
|
||||||
("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
|
|
||||||
("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
|
|
||||||
("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
|
|
||||||
("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
|
|
||||||
("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
|
|
||||||
("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
|
|
||||||
("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def expand(raw: str) -> Path:
|
|
||||||
return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def candidate_paths(text: str) -> set[str]:
|
|
||||||
return {
|
|
||||||
raw
|
|
||||||
for raw in PATH_RE.findall(text)
|
|
||||||
if not any(s in raw for s in SKIP_SUBSTRINGS)
|
|
||||||
and raw not in PROSE_FRAGMENTS
|
|
||||||
and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def prompt_roots(text: str) -> list[Path]:
|
|
||||||
roots = []
|
|
||||||
for label in ("Repo", "App", "Flutter app"):
|
|
||||||
for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
|
|
||||||
roots.append(expand(m.group(1)))
|
|
||||||
return roots
|
|
||||||
|
|
||||||
|
|
||||||
def check_paths() -> list[str]:
|
|
||||||
failures = []
|
|
||||||
checked = 0
|
|
||||||
for prompt in sorted(PROMPT_DIR.glob("*.md")):
|
|
||||||
text = prompt.read_text()
|
|
||||||
roots = prompt_roots(text)
|
|
||||||
for raw in sorted(candidate_paths(text)):
|
|
||||||
if raw in ASSERTED_ABSENT:
|
|
||||||
if expand(raw).exists():
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if raw in CREATED_BY_PROMPT:
|
|
||||||
owner = CREATED_BY_PROMPT[raw]
|
|
||||||
cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
|
|
||||||
if not prompt.name.startswith(owner) and not cites_owner:
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: cites `{raw}` without noting prompt "
|
|
||||||
f"{owner} creates it"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
checked += 1
|
|
||||||
if raw.startswith(("~/", "/")):
|
|
||||||
if not expand(raw).exists():
|
|
||||||
failures.append(f"{prompt.name}: `{raw}` MISSING")
|
|
||||||
elif not any((r / raw).exists() for r in roots):
|
|
||||||
failures.append(
|
|
||||||
f"{prompt.name}: `{raw}` not found under any of "
|
|
||||||
f"{[str(r) for r in roots]}"
|
|
||||||
)
|
|
||||||
print(f"paths: checked {checked}")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def check_symbols() -> list[str]:
|
|
||||||
failures = []
|
|
||||||
for raw_file, symbol in SYMBOL_CLAIMS:
|
|
||||||
path = expand(raw_file)
|
|
||||||
if not path.exists():
|
|
||||||
failures.append(f"symbol source MISSING: {raw_file}")
|
|
||||||
continue
|
|
||||||
if symbol not in path.read_text(errors="replace"):
|
|
||||||
failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
|
|
||||||
print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
n_prompts = len(list(PROMPT_DIR.glob("*.md")))
|
|
||||||
print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
|
|
||||||
failures = check_paths() + check_symbols()
|
|
||||||
if failures:
|
|
||||||
print(f"\nFAIL -- {len(failures)} problem(s):")
|
|
||||||
for f in failures:
|
|
||||||
print(f" {f}")
|
|
||||||
return 1
|
|
||||||
print("\nPASS -- every cited path exists and every cited symbol is present")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@ -132,10 +132,7 @@ def rewrite(text: str, old: Path, new: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
#: The remaining files are plain reference docs, renamed mechanically to
|
#: The remaining files are plain reference docs, renamed mechanically to
|
||||||
#: DOCS-<existing-slug>.md. The one exception is the dopamine-ux programme:
|
#: DOCS-<existing-slug>.md.
|
||||||
#: 00-INDEX records 8 of its 9 parts as "not started", so those parts are
|
|
||||||
#: outstanding work and become TODO, not DOCS. The index itself is a record.
|
|
||||||
AUTO_TASK_PREFIXES = ("utils/dopamine-ux/0",)
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(stem: str) -> str:
|
def slugify(stem: str) -> str:
|
||||||
@ -150,8 +147,6 @@ def auto_name(rel: str) -> tuple[str, str]:
|
|||||||
# A misspelled README is a README, not a doc needing a new namespace.
|
# A misspelled README is a README, not a doc needing a new namespace.
|
||||||
if stem.lower() == "readme":
|
if stem.lower() == "readme":
|
||||||
return "README.md", "record"
|
return "README.md", "record"
|
||||||
if rel.startswith(AUTO_TASK_PREFIXES) and "INDEX" not in stem:
|
|
||||||
return f"TODO-{slugify(stem)}.md", "task"
|
|
||||||
return f"DOCS-{slugify(stem)}.md", "record"
|
return f"DOCS-{slugify(stem)}.md", "record"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,6 @@
|
|||||||
"testsAndMisc/docs/shell-split-verification.md": "DOCS-shell-split-verification.md",
|
"testsAndMisc/docs/shell-split-verification.md": "DOCS-shell-split-verification.md",
|
||||||
"testsAndMisc/linux_configuration/docs/SUMMARY.md": "DOCS-pacman-security-layers.md",
|
"testsAndMisc/linux_configuration/docs/SUMMARY.md": "DOCS-pacman-security-layers.md",
|
||||||
"utils/gatelock/docs/scrollable-viewport.md": "DOCS-scrollable-viewport.md",
|
"utils/gatelock/docs/scrollable-viewport.md": "DOCS-scrollable-viewport.md",
|
||||||
"utils/dopamine-ux/00-INDEX.md": "DOCS-dopamine-ux-index.md",
|
|
||||||
"digital-wellbeing/llm-notes/units-and-tasks.md": "DOCS-shutdown-units-reference.md",
|
"digital-wellbeing/llm-notes/units-and-tasks.md": "DOCS-shutdown-units-reference.md",
|
||||||
"digital-wellbeing/README_COMPULSIVE_BLOCK_LLM.md": "DOCS-compulsive-block-reference.md",
|
"digital-wellbeing/README_COMPULSIVE_BLOCK_LLM.md": "DOCS-compulsive-block-reference.md",
|
||||||
"digital-wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md": "DOCS-midnight-shutdown-reference.md",
|
"digital-wellbeing/README_MIDNIGHT_SHUTDOWN_LLM.md": "DOCS-midnight-shutdown-reference.md",
|
||||||
|
|||||||
@ -90,12 +90,6 @@ def test_auto_name_fixes_readme_casing() -> None:
|
|||||||
assert auto_name("x/Readme.md") == ("README.md", "record")
|
assert auto_name("x/Readme.md") == ("README.md", "record")
|
||||||
|
|
||||||
|
|
||||||
def test_dopamine_ux_parts_are_tasks_but_index_is_not() -> None:
|
|
||||||
"""00-INDEX records the programme; 01-09 are its unstarted parts."""
|
|
||||||
assert auto_name("utils/dopamine-ux/03-diet-guard.md")[1] == "task"
|
|
||||||
assert auto_name("utils/dopamine-ux/00-INDEX.md")[1] == "record"
|
|
||||||
|
|
||||||
|
|
||||||
def test_classified_paths_do_not_overlap() -> None:
|
def test_classified_paths_do_not_overlap() -> None:
|
||||||
"""A file cannot be both a task and a record."""
|
"""A file cannot be both a task and a record."""
|
||||||
assert not set(classify.TASKS) & set(classify.RECORDS)
|
assert not set(classify.TASKS) & set(classify.RECORDS)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user