diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6616b7f..751e7b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,4 +18,4 @@ jobs: run: sudo apt-get install -y jq - name: Run tests - run: ./tests/test_helper/bats-core/bin/bats tests/hooks/ tests/install/ + run: ./tests/test_helper/bats-core/bin/bats tests/hooks/ tests/install/ tests/bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb7e3f..840fda8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,15 @@ All notable changes to TrimKit are documented here. - `prod-debug` skill — pre-loads DB schema and container registry for production debugging sessions - Sysops audit log — `/sysops log` command; appends session/project-stamped entries to a JSONL file +### Update check +- Periodic update notifications — TTL-cached (24h) check against `main` branch on GitHub; silent when up-to-date or on fetch failure; auto-snoozes per-version for 7 days after notifying +- `trimkit-update-check` — bin script called as a `UserPromptSubmit` hook on each Claude Code session +- `trimkit-update-snooze` — bin script that writes snooze state; also callable manually + ### Installer - Plugin bootstrapping via `plugins/plugins.txt` - `--upgrade` flag to replace existing real files with symlinks (backs up originals) - Hooks auto-merged into `~/.claude/settings.json` on install - CLAUDE.md injection — writes TrimKit hook compatibility tips into `~/.claude/CLAUDE.md` - Misc quality-of-life: interactive PATH prompt, improved install summary output +- Writes `~/.trimkit/install-dir` so `trimkit-update-check` can locate the local `package.json` diff --git a/bin/trimkit-update-check b/bin/trimkit-update-check new file mode 100755 index 0000000..a3b53bd --- /dev/null +++ b/bin/trimkit-update-check @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# trimkit-update-check — Check for available trimkit updates. +# +# Runs at most once per 24 hours (TTL-cached). Fetches the latest version +# from GitHub and compares against the locally installed version. Prints a +# one-line notice if an update is available; silent otherwise. +# +# Called as a UserPromptSubmit hook on every Claude Code prompt submission. +# Always exits 0 — failure must never block Claude Code. +# +# State files (all under STATE_DIR): +# last-checked ISO 8601 timestamp of last remote fetch +# latest-version cached version string from GitHub +# snoozed-version version string that is currently snoozed +# snoozed-until ISO 8601 timestamp when snooze expires +# +# Environment overrides (for testing): +# TRIMKIT_UPDATE_STATE_DIR state directory (default: ~/.trimkit/update-check) +# TRIMKIT_UPDATE_INSTALL_DIR trimkit install dir (default: read from ~/.trimkit/install-dir) +# TRIMKIT_UPDATE_REMOTE_URL remote package.json URL +# TRIMKIT_UPDATE_TTL fetch TTL in seconds (default: 86400) +set -euo pipefail + +STATE_DIR="${TRIMKIT_UPDATE_STATE_DIR:-${HOME}/.trimkit/update-check}" +REMOTE_URL="${TRIMKIT_UPDATE_REMOTE_URL:-https://raw.githubusercontent.com/josephfung/trimkit/main/package.json}" +TTL="${TRIMKIT_UPDATE_TTL:-86400}" + +# All logic is wrapped in run_check so we can safely 'return' on any error +# without affecting the outer exit 0. +run_check() { + local install_dir pkg_json local_version + local ttl_expired last_checked now remote_json latest_version + local is_newer snoozed_version snoozed_until + + # Resolve the trimkit install directory + if [ -n "${TRIMKIT_UPDATE_INSTALL_DIR:-}" ]; then + install_dir="$TRIMKIT_UPDATE_INSTALL_DIR" + elif [ -f "${HOME}/.trimkit/install-dir" ]; then + install_dir="$(cat "${HOME}/.trimkit/install-dir")" + else + # Not installed via install.sh — skip check silently + return 0 + fi + + # Read local version from the trimkit clone's package.json + pkg_json="$install_dir/package.json" + [ -f "$pkg_json" ] || return 0 + local_version="$(python3 -c " +import json, sys +print(json.load(open(sys.argv[1]))['version']) +" "$pkg_json" 2>/dev/null)" || return 0 + [ -n "$local_version" ] || return 0 + + # Ensure state directory exists + mkdir -p "$STATE_DIR" 2>/dev/null || return 0 + + # Check whether the last-checked timestamp is within TTL + ttl_expired=1 + if [ -f "$STATE_DIR/last-checked" ]; then + last_checked="$(cat "$STATE_DIR/last-checked" 2>/dev/null)" + if [ -n "$last_checked" ]; then + python3 -c " +from datetime import datetime, timezone +import sys +last = datetime.fromisoformat(sys.argv[1].replace('Z', '+00:00')) +now_dt = datetime.now(timezone.utc) +sys.exit(0 if (now_dt - last).total_seconds() < float(sys.argv[2]) else 1) +" "$last_checked" "$TTL" 2>/dev/null && ttl_expired=0 + fi + fi + + # Fetch the remote version if the TTL has expired + if [ "$ttl_expired" -eq 1 ]; then + # Compute current timestamp lazily — only needed here for writing last-checked + now="$(python3 -c " +from datetime import datetime, timezone +print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) +" 2>/dev/null)" || return 0 + + # Update last-checked before the fetch so a network failure still backs off + # for the full TTL — avoids a 3-second curl timeout on every prompt when the + # network is flaky. + printf '%s\n' "$now" > "$STATE_DIR/last-checked.tmp" \ + && mv "$STATE_DIR/last-checked.tmp" "$STATE_DIR/last-checked" \ + || return 0 + + remote_json="$(curl -sf --max-time 3 "$REMOTE_URL" 2>/dev/null)" || return 0 + + latest_version="$(printf '%s' "$remote_json" | python3 -c " +import json, sys +print(json.load(sys.stdin)['version']) +" 2>/dev/null)" || return 0 + [ -n "$latest_version" ] || return 0 + + printf '%s\n' "$latest_version" > "$STATE_DIR/latest-version.tmp" \ + && mv "$STATE_DIR/latest-version.tmp" "$STATE_DIR/latest-version" \ + || return 0 + fi + + # Read the cached latest version (may have just been written above) + [ -f "$STATE_DIR/latest-version" ] || return 0 + latest_version="$(cat "$STATE_DIR/latest-version" 2>/dev/null)" + [ -n "$latest_version" ] || return 0 + + # Compare versions — is latest strictly newer than local? + # Uses a 4-tuple (major, minor, patch, is_release) so pre-release versions + # (e.g. 1.0.0-rc.1) sort below their corresponding release (1.0.0), meaning + # a user on 1.0.0-rc.1 will be notified when 1.0.0 ships. + is_newer="$(python3 -c " +import sys, re +def parse(v): + m = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?([-+].*)?', v.strip()) + if not m: + return (0, 0, 0, 1) + major = int(m.group(1) or 0) + minor = int(m.group(2) or 0) + patch = int(m.group(3) or 0) + pre = 0 if m.group(4) else 1 # pre-release sorts below release + return (major, minor, patch, pre) +print('1' if parse(sys.argv[2]) > parse(sys.argv[1]) else '0') +" "$local_version" "$latest_version" 2>/dev/null)" || return 0 + [ "$is_newer" = "1" ] || return 0 + + # Check snooze — suppress the notice if this version is still snoozed + if [ -f "$STATE_DIR/snoozed-version" ] && [ -f "$STATE_DIR/snoozed-until" ]; then + snoozed_version="$(cat "$STATE_DIR/snoozed-version" 2>/dev/null)" + snoozed_until="$(cat "$STATE_DIR/snoozed-until" 2>/dev/null)" + if [ "$snoozed_version" = "$latest_version" ] && [ -n "$snoozed_until" ]; then + python3 -c " +from datetime import datetime, timezone +import sys +until = datetime.fromisoformat(sys.argv[1].replace('Z', '+00:00')) +sys.exit(0 if datetime.now(timezone.utc) < until else 1) +" "$snoozed_until" 2>/dev/null && return 0 + fi + fi + + # Print the one-line update notice. %q shell-quotes the install path so the + # generated command is copy-pasteable even when the path contains spaces. + printf 'trimkit v%s is available (you have v%s). Run: cd %q; git pull; ./install.sh\n' \ + "$latest_version" "$local_version" "$install_dir" + + # Auto-snooze via the dedicated script to avoid repeating on every subsequent + # prompt until the next TTL expiry. Best-effort — if it fails the notice + # recurs after the TTL (24 h), not on every message. + "$(dirname "${BASH_SOURCE[0]}")/trimkit-update-snooze" "$latest_version" 2>/dev/null || true +} + +run_check +exit 0 diff --git a/bin/trimkit-update-snooze b/bin/trimkit-update-snooze new file mode 100755 index 0000000..6a75326 --- /dev/null +++ b/bin/trimkit-update-snooze @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# trimkit-update-snooze — Snooze trimkit update notifications for a version. +# +# Usage: trimkit-update-snooze +# +# Writes snoozed-version and snoozed-until into the update-check state dir. +# trimkit-update-check calls this automatically after showing a notice, but +# it can also be run manually to snooze a specific version. +# +# Environment overrides (for testing): +# TRIMKIT_UPDATE_STATE_DIR state directory (default: ~/.trimkit/update-check) +# TRIMKIT_UPDATE_SNOOZE_DAYS snooze duration in days (default: 7) +set -euo pipefail + +STATE_DIR="${TRIMKIT_UPDATE_STATE_DIR:-${HOME}/.trimkit/update-check}" +SNOOZE_DAYS="${TRIMKIT_UPDATE_SNOOZE_DAYS:-7}" + +if [ $# -lt 1 ] || [ -z "$1" ]; then + echo "trimkit-update-snooze: error: missing or empty version argument" >&2 + echo "Usage: trimkit-update-snooze " >&2 + exit 1 +fi + +version="$1" + +mkdir -p "$STATE_DIR" || { + echo "trimkit-update-snooze: error: cannot create state directory '$STATE_DIR'" >&2 + exit 1 +} + +snooze_until="$(python3 -c " +from datetime import datetime, timezone, timedelta +import sys +days = max(1, int(sys.argv[1])) # clamp to at least 1 day; negative/zero would expire immediately +until = datetime.now(timezone.utc) + timedelta(days=days) +print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) +" "$SNOOZE_DAYS")" + +printf '%s\n' "$version" > "$STATE_DIR/snoozed-version.tmp" \ + && mv "$STATE_DIR/snoozed-version.tmp" "$STATE_DIR/snoozed-version" || { + echo "trimkit-update-snooze: error: cannot write snoozed-version" >&2 + exit 1 +} + +printf '%s\n' "$snooze_until" > "$STATE_DIR/snoozed-until.tmp" \ + && mv "$STATE_DIR/snoozed-until.tmp" "$STATE_DIR/snoozed-until" || { + echo "trimkit-update-snooze: error: cannot write snoozed-until" >&2 + exit 1 +} diff --git a/install.sh b/install.sh index 2a4909b..3258299 100755 --- a/install.sh +++ b/install.sh @@ -174,6 +174,18 @@ symlink_dirs "$SCRIPT_DIR/skills" "$HOME/.claude/skills" _installed_before_bin=${#installed[@]} symlink_files "$SCRIPT_DIR/bin" "$HOME/.trimkit/bin" +# --------------------------------------------------------------------------- +# Install directory record (used by trimkit-update-check to find package.json) +# --------------------------------------------------------------------------- + +# Write the absolute path of this trimkit clone to ~/.trimkit/install-dir so +# trimkit-update-check can locate the local package.json without resolving +# symlinks (which is not portable across macOS and Linux). +if ! { printf '%s\n' "$SCRIPT_DIR" > "$HOME/.trimkit/install-dir.tmp" \ + && mv "$HOME/.trimkit/install-dir.tmp" "$HOME/.trimkit/install-dir"; }; then + warned+=("install-dir (could not write ~/.trimkit/install-dir — update checks will be skipped)") +fi + # --------------------------------------------------------------------------- # Plugins # --------------------------------------------------------------------------- diff --git a/package.json b/package.json new file mode 100644 index 0000000..d3a0e2e --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "trimkit", + "version": "0.5.0", + "description": "Claude Code guardrails and utilities", + "repository": { + "type": "git", + "url": "https://github.com/josephfung/trimkit.git" + }, + "homepage": "https://github.com/josephfung/trimkit", + "license": "MIT" +} diff --git a/settings/hooks.json b/settings/hooks.json index f57fb54..98da105 100644 --- a/settings/hooks.json +++ b/settings/hooks.json @@ -1,6 +1,18 @@ { "_comment": "Merge this into your ~/.claude/settings.json under hooks", "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "~/.trimkit/bin/trimkit-update-check", + "statusMessage": "Checking for trimkit updates..." + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", diff --git a/tests/bin/trimkit-update-check.bats b/tests/bin/trimkit-update-check.bats new file mode 100644 index 0000000..226ee07 --- /dev/null +++ b/tests/bin/trimkit-update-check.bats @@ -0,0 +1,271 @@ +#!/usr/bin/env bats + +# tests/bin/trimkit-update-check.bats — tests for bin/trimkit-update-check + +setup() { + load '../test_helper/bats-support/load' + load '../test_helper/bats-assert/load' + + SCRIPT="$BATS_TEST_DIRNAME/../../bin/trimkit-update-check" + + FAKE_HOME="$(mktemp -d)" + FAKE_STATE_DIR="$FAKE_HOME/update-check" + FAKE_INSTALL_DIR="$FAKE_HOME/trimkit" + MOCK_BIN="$(mktemp -d)" + MOCK_CURL_CALLS="$FAKE_HOME/curl-calls" + MOCK_REMOTE_JSON="$FAKE_HOME/remote.json" + + # Local install: version 0.5.0 + mkdir -p "$FAKE_INSTALL_DIR" + printf '{"name":"trimkit","version":"0.5.0"}\n' > "$FAKE_INSTALL_DIR/package.json" + + # Remote: same version by default (no update available) + printf '{"name":"trimkit","version":"0.5.0"}\n' > "$MOCK_REMOTE_JSON" + + # Call-count file — starts empty; mock curl appends one line per call + touch "$MOCK_CURL_CALLS" + + # Mock curl — outputs $MOCK_CURL_RESPONSE, records successful calls, honours $MOCK_CURL_EXIT. + # The call counter is incremented only on non-error exits so that tests asserting + # "no curl call" aren't confused by failure-path invocations. + cat > "$MOCK_BIN/curl" <<'MOCKCURL' +#!/bin/bash +if [ "${MOCK_CURL_EXIT:-0}" != "0" ]; then + exit "${MOCK_CURL_EXIT}" +fi +printf '1\n' >> "${MOCK_CURL_CALLS}" +cat "${MOCK_CURL_RESPONSE}" +MOCKCURL + chmod +x "$MOCK_BIN/curl" + + # Override HOME so that any $HOME-based fallback paths in the script resolve + # into the sandbox rather than the real user's ~/.trimkit directory. + export HOME="$FAKE_HOME" + export PATH="$MOCK_BIN:$PATH" + export TRIMKIT_UPDATE_STATE_DIR="$FAKE_STATE_DIR" + export TRIMKIT_UPDATE_INSTALL_DIR="$FAKE_INSTALL_DIR" + export TRIMKIT_UPDATE_REMOTE_URL="https://fake.example.com/package.json" + export TRIMKIT_UPDATE_TTL="86400" + export TRIMKIT_UPDATE_SNOOZE_DAYS="7" + export MOCK_CURL_CALLS + export MOCK_CURL_RESPONSE="$MOCK_REMOTE_JSON" + unset MOCK_CURL_EXIT +} + +teardown() { + rm -rf "$FAKE_HOME" "$MOCK_BIN" +} + +# --------------------------------------------------------------------------- +# Basic contract +# --------------------------------------------------------------------------- + +@test "script exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "always exits 0" { + run bash "$SCRIPT" + assert_success +} + +# --------------------------------------------------------------------------- +# Install-dir detection +# --------------------------------------------------------------------------- + +@test "silent when TRIMKIT_UPDATE_INSTALL_DIR is unset and no install-dir file" { + unset TRIMKIT_UPDATE_INSTALL_DIR + run bash "$SCRIPT" + assert_success + assert_output '' +} + +# --------------------------------------------------------------------------- +# Version comparison +# --------------------------------------------------------------------------- + +@test "silent when remote version equals local" { + # Default setup: both 0.5.0 + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "prints notice when remote is newer" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v1.0.0 is available' + assert_output --partial 'you have v0.5.0' +} + +@test "notice includes update command and actual install dir" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_output --partial 'git pull' + assert_output --partial 'install.sh' + assert_output --partial "$FAKE_INSTALL_DIR" +} + +@test "silent when remote is older than local" { + printf '{"name":"trimkit","version":"0.4.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_success + assert_output '' +} + +# --------------------------------------------------------------------------- +# TTL cache +# --------------------------------------------------------------------------- + +@test "does not call curl when last-checked is within TTL" { + # Pre-populate cache with current timestamp and a cached version + mkdir -p "$FAKE_STATE_DIR" + python3 -c " +from datetime import datetime, timezone +print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/last-checked" + printf '0.5.0\n' > "$FAKE_STATE_DIR/latest-version" + + run bash "$SCRIPT" + assert_success + + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "0" +} + +@test "calls curl when last-checked is absent (first run)" { + run bash "$SCRIPT" + assert_success + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "1" +} + +@test "calls curl when last-checked is stale" { + mkdir -p "$FAKE_STATE_DIR" + printf '2000-01-01T00:00:00Z\n' > "$FAKE_STATE_DIR/last-checked" + + run bash "$SCRIPT" + assert_success + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "1" +} + +@test "uses cached latest-version to show notice without calling curl" { + # TTL is fresh; cached version is newer + mkdir -p "$FAKE_STATE_DIR" + python3 -c " +from datetime import datetime, timezone +print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/last-checked" + printf '1.0.0\n' > "$FAKE_STATE_DIR/latest-version" + + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v1.0.0 is available' + assert_equal "$(wc -l < "$MOCK_CURL_CALLS" | tr -d ' ')" "0" +} + +@test "writes last-checked after fetch" { + run bash "$SCRIPT" + assert_success + [ -f "$FAKE_STATE_DIR/last-checked" ] + python3 -c " +from datetime import datetime +ts = open('$FAKE_STATE_DIR/last-checked').read().strip() +datetime.fromisoformat(ts.replace('Z', '+00:00')) # raises if invalid +" +} + +# --------------------------------------------------------------------------- +# Fetch failures +# --------------------------------------------------------------------------- + +@test "silent and exits 0 on fetch failure" { + export MOCK_CURL_EXIT=1 + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "updates last-checked even when fetch fails" { + export MOCK_CURL_EXIT=1 + run bash "$SCRIPT" + assert_success + [ -f "$FAKE_STATE_DIR/last-checked" ] +} + +# --------------------------------------------------------------------------- +# Snooze +# --------------------------------------------------------------------------- + +@test "writes auto-snooze files after showing notice" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + run bash "$SCRIPT" + assert_success + [ -f "$FAKE_STATE_DIR/snoozed-version" ] + [ -f "$FAKE_STATE_DIR/snoozed-until" ] + assert_equal "$(cat "$FAKE_STATE_DIR/snoozed-version")" "1.0.0" +} + +@test "silent during active snooze" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + mkdir -p "$FAKE_STATE_DIR" + printf '1.0.0\n' > "$FAKE_STATE_DIR/snoozed-version" + python3 -c " +from datetime import datetime, timezone, timedelta +until = datetime.now(timezone.utc) + timedelta(days=7) +print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/snoozed-until" + + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "second run is silent after auto-snooze is written" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + + # First run: notice shown, snooze written (output captured to keep harness clean) + run bash "$SCRIPT" + assert_success + + # Second run: snoozed — must be silent + run bash "$SCRIPT" + assert_success + assert_output '' +} + +@test "shows notice when snooze has expired" { + printf '{"name":"trimkit","version":"1.0.0"}\n' > "$MOCK_REMOTE_JSON" + mkdir -p "$FAKE_STATE_DIR" + printf '1.0.0\n' > "$FAKE_STATE_DIR/snoozed-version" + printf '2000-01-01T00:00:00Z\n' > "$FAKE_STATE_DIR/snoozed-until" + + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v1.0.0 is available' +} + +@test "shows notice when a different (older) version is snoozed" { + printf '{"name":"trimkit","version":"2.0.0"}\n' > "$MOCK_REMOTE_JSON" + mkdir -p "$FAKE_STATE_DIR" + # Snoozed for 1.0.0, but remote is now 2.0.0 + printf '1.0.0\n' > "$FAKE_STATE_DIR/snoozed-version" + python3 -c " +from datetime import datetime, timezone, timedelta +until = datetime.now(timezone.utc) + timedelta(days=7) +print(until.strftime('%Y-%m-%dT%H:%M:%SZ')) +" > "$FAKE_STATE_DIR/snoozed-until" + + run bash "$SCRIPT" + assert_success + assert_output --partial 'trimkit v2.0.0 is available' +} + +# --------------------------------------------------------------------------- +# State directory +# --------------------------------------------------------------------------- + +@test "creates state directory if it does not exist" { + run bash "$SCRIPT" + assert_success + [ -d "$FAKE_STATE_DIR" ] +} diff --git a/tests/bin/trimkit-update-snooze.bats b/tests/bin/trimkit-update-snooze.bats new file mode 100644 index 0000000..3179f8c --- /dev/null +++ b/tests/bin/trimkit-update-snooze.bats @@ -0,0 +1,121 @@ +#!/usr/bin/env bats + +# tests/bin/trimkit-update-snooze.bats — tests for bin/trimkit-update-snooze + +setup() { + load '../test_helper/bats-support/load' + load '../test_helper/bats-assert/load' + + SCRIPT="$BATS_TEST_DIRNAME/../../bin/trimkit-update-snooze" + + FAKE_HOME="$(mktemp -d)" + FAKE_STATE_DIR="$FAKE_HOME/update-check" + + export TRIMKIT_UPDATE_STATE_DIR="$FAKE_STATE_DIR" + export TRIMKIT_UPDATE_SNOOZE_DAYS="7" +} + +teardown() { + rm -rf "$FAKE_HOME" +} + +# --------------------------------------------------------------------------- +# Basic contract +# --------------------------------------------------------------------------- + +@test "script exists and is executable" { + [ -x "$SCRIPT" ] +} + +@test "exits non-zero and prints usage on missing version argument" { + run bash "$SCRIPT" + assert_failure + assert_output --partial 'version argument' + assert_output --partial 'Usage:' +} + +# --------------------------------------------------------------------------- +# State file content +# --------------------------------------------------------------------------- + +@test "writes correct snoozed-version" { + run bash "$SCRIPT" "1.2.3" + assert_success + [ -f "$FAKE_STATE_DIR/snoozed-version" ] + assert_equal "$(cat "$FAKE_STATE_DIR/snoozed-version")" "1.2.3" +} + +@test "snoozed-version is a single line" { + bash "$SCRIPT" "1.2.3" + assert_equal "$(wc -l < "$FAKE_STATE_DIR/snoozed-version" | tr -d ' ')" "1" +} + +@test "writes snoozed-until in ISO 8601 UTC format" { + run bash "$SCRIPT" "1.2.3" + assert_success + [ -f "$FAKE_STATE_DIR/snoozed-until" ] + python3 -c ' +import re, sys +ts = open(sys.argv[1]).read().strip() +assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", ts), f"bad format: {ts}" +' "$FAKE_STATE_DIR/snoozed-until" +} + +@test "snoozed-until is approximately SNOOZE_DAYS in the future" { + bash "$SCRIPT" "1.2.3" + python3 -c ' +from datetime import datetime, timezone +import sys +ts = open(sys.argv[1]).read().strip() +until = datetime.fromisoformat(ts.replace("Z", "+00:00")) +delta = (until - datetime.now(timezone.utc)).total_seconds() +# Allow 60-second tolerance for test execution time +assert abs(delta - 7 * 86400) < 60, f"expected ~7 days, got {delta:.0f}s" +' "$FAKE_STATE_DIR/snoozed-until" +} + +@test "respects TRIMKIT_UPDATE_SNOOZE_DAYS override" { + export TRIMKIT_UPDATE_SNOOZE_DAYS=3 + bash "$SCRIPT" "1.2.3" + python3 -c ' +from datetime import datetime, timezone +import sys +ts = open(sys.argv[1]).read().strip() +until = datetime.fromisoformat(ts.replace("Z", "+00:00")) +delta = (until - datetime.now(timezone.utc)).total_seconds() +assert abs(delta - 3 * 86400) < 60, f"expected ~3 days, got {delta:.0f}s" +' "$FAKE_STATE_DIR/snoozed-until" +} + +# --------------------------------------------------------------------------- +# State directory +# --------------------------------------------------------------------------- + +@test "creates state directory if it does not exist" { + run bash "$SCRIPT" "1.2.3" + assert_success + [ -d "$FAKE_STATE_DIR" ] +} + +# --------------------------------------------------------------------------- +# Idempotency +# --------------------------------------------------------------------------- + +@test "second call with same version overwrites snooze" { + bash "$SCRIPT" "1.2.3" + first_until="$(cat "$FAKE_STATE_DIR/snoozed-until")" + + bash "$SCRIPT" "1.2.3" + second_until="$(cat "$FAKE_STATE_DIR/snoozed-until")" + + # Both should be valid timestamps (not checking exact equality due to + # sub-second timing; just confirming the file is rewritten cleanly) + [ -n "$first_until" ] + [ -n "$second_until" ] +} + +@test "second call with different version updates snoozed-version" { + bash "$SCRIPT" "1.0.0" + bash "$SCRIPT" "2.0.0" + assert_equal "$(cat "$FAKE_STATE_DIR/snoozed-version")" "2.0.0" +}