From 6f9fbf227f2cd7a661dcda9505f234bd8fe9c5c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 04:41:37 +0000 Subject: [PATCH 1/2] feat(sync-project): --only-bin installs just the named scripts (#390) --upgrade-bin only lifts the overwrite refusal for a modified copy; the full sync still runs. A field run used it as "install just these scripts" and got 244 changed tracked files, a deletion, ~30 additions and reinstalled hooks. --only-bin X [--only-bin Y] copies only project-bin/X into /bin/, decides everything before writing (unknown/path-like name or an unaccepted local modification exits 1, project untouched), backs up an overwritten copy (--upgrade-bin X or --force), prints every file written, and exits before any other sync section runs. Docs and remedy lines now say --upgrade-bin is a full sync and point scoped installs at --only-bin. Fixture tests/wave2/test-sync-only-bin.sh: 26/26 on single- and two-tree. test-bug12-sync.sh: 61/61. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 1 + bin/doctor.sh | 2 +- bin/harvest-learnings.sh | 4 +- bin/sync-project.sh | 111 ++++++++++++++++++++++++--- project-bin/app-restart.sh | 4 +- project-bin/test-scratch.sh | 2 +- project-bin/test-stack-up.sh | 2 +- tests/wave2/test-sync-only-bin.sh | 121 ++++++++++++++++++++++++++++++ 8 files changed, 229 insertions(+), 18 deletions(-) create mode 100755 tests/wave2/test-sync-only-bin.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 994f1202..a52fb3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- new(sync-project): **`bin/sync-project.sh --only-bin [--only-bin …]` installs only the named `project-bin/` scripts into the project's `bin/` and touches nothing else; `--upgrade-bin` is now documented as what it is — a full sync that may also overwrite a modified copy.** A field run used `--upgrade-bin exec.sh --upgrade-bin _common.sh --upgrade-bin test-scratch.sh` as "install just these scripts" and got a full sync: 244 tracked files changed, one deleted, ~30 added, git hooks reinstalled. `--only-bin` decides everything before writing anything (an unknown or path-like name, or a locally modified copy without `--upgrade-bin `/`--force`, exits 1 with the project untouched), backs up an overwritten copy, and prints every file it wrote. The remedy lines in `app-restart.sh`, `test-scratch.sh`, `test-stack-up.sh`, `doctor.sh` and `harvest-learnings.sh` now name `--only-bin`. Fixture `tests/wave2/test-sync-only-bin.sh` (26 checks, single- and two-tree): one `git status` change, hooks and skills untouched, while a full `--upgrade-bin` sync of the same project installs other scripts and the hooks. — field run, issue #390 - new(tests/behaviour): **a standing behaviour suite for agent-read text: arms M (base ref), T (candidate ref, or the base minus one skill for an ablation) and N (no pack), two drafting tasks with planted STOP-row requests and five retrieval probes, scored mechanically.** `tests/behaviour/build-packs.sh` builds a pack from any ref in a temporary worktree; `score.sh` adds `read_all` (the agent quoted the pack's `PACK-END` line back), `tool_calls` and `tokens`, and treats `mxcli check` as optional (NA without a binary). `RUNBOOK.md` has the triggers (every PR that changes baseline-routed text or an agent stub; N and ablations at each release cut), 5 reps x 2 tasks plus 5 probes x 3 reps, the rule (T no worse than M on every scored metric, probes T >= M - 1), and partial reads counted and re-run, never dropped. `docs/RELEASE-PROCESS.md` §4a names it as the way to run the Behaviour A/B. Field runs committed under `tests/behaviour/results/`: the 2026-10-15 stub-diet A/B (#369/#378/#380, 42 reps, 3 partial reads on the 4,822-line pack) and the first no-pack run (page trap refused 0/5 without a pack against 5/5 with either; mf passes check 1/5 against 5/5; every arm, N included, also inherited the host session's project CLAUDE.md (a generic mxcli/MDL guide), so N measures 'no toolkit rules', not 'no guidance'). Golden input: four real agent outputs, `tests/wave2/test-behaviour-score.sh`. — maintainer request (MendixMau) - process(pr-template, release-process): **every Direct-lane PR and every review fills a five-row Impact block: Process, Dependencies, Context cost, Behaviour, Runtime.** "none" or "NOT RUN: " is legal, a blank row is not. Context cost is pasted from `bin/render-routing.sh --check` on base and head, never estimated; a missing or estimated row is a review finding. The Behaviour A/B (`process/preflight-skill-baseline-2026-08-31.md`) is required for baseline-routed text and agent stubs, recommended for situational skills, NOT RUN with a reason for process docs review agents read, n/a for scripts only; Context cost may be "none" when no routed or always-loaded file is touched. `docs/RELEASE-PROCESS.md` §4a holds the rule and a bad/good example; `CONTRIBUTING.md`'s reviewer list points there. No CI check: `bin/check-pr-discipline.sh` reads the git diff, not the PR body. Field evidence: three PRs reviewed on 2026-10-15 with no process cost stated (#375 added a human step per re-baseline, #384 adds 1–11 s per exec), both found by chance. — maintainer request, MendixMau - learn(learned-detection-gaps, testing-shape, ui-loop): **a class on a snippet call never reaches the DOM; a 403 bounce and a widget-pinned session probe both read as sign-outs; app restarts matched a "signs me out" report by timing.** Detection-gaps row: `Class` on a `SNIPPETCALL` is green through check, exec, mxbuild, lint and DESCRIBE, and absent in the browser ([#336](https://github.com/MendixMau/mxcli-project-toolkit/issues/336)). Testing-shape §4 gains two false-red rows: `login.html?messageCode=403` with the session kept ([#338](https://github.com/MendixMau/mxcli-project-toolkit/issues/338)), and instruments pinned to one grid name or a typed menu list (1/7 and 7/34 false FAILs after a redesign, [#339](https://github.com/MendixMau/mxcli-project-toolkit/issues/339)). ui-loop: say so before a restart while a human uses the app ([#337](https://github.com/MendixMau/mxcli-project-toolkit/issues/337)) — three-app field run, 2026-10-14 diff --git a/bin/doctor.sh b/bin/doctor.sh index 703c2b3d..ef25d8b6 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -318,7 +318,7 @@ ps_apply() { _psa_ex="$PROJECT_DIR/bin/exec.sh" if [ ! -x "$_psa_ex" ] || ! grep -q 'PATCH_MODE' "$_psa_ex" 2>/dev/null; then bad "property sets: not repaired — this project's bin/exec.sh has no --patch, and the repair is only run through it" - note "upgrade it first: bin/sync-project.sh $PROJECT_DIR --upgrade-bin exec.sh" + note "upgrade it first: bin/sync-project.sh $PROJECT_DIR --only-bin exec.sh --upgrade-bin exec.sh" return 0 fi note "repairing through bin/exec.sh --patch (snapshot, fix, mxbuild gate, kept only on a verified gate)…" diff --git a/bin/harvest-learnings.sh b/bin/harvest-learnings.sh index 5f9fccf4..0e3a77a1 100755 --- a/bin/harvest-learnings.sh +++ b/bin/harvest-learnings.sh @@ -190,8 +190,8 @@ COMMITSEOF STALE_N=$((STALE_N + 1)) # , never $PROJECT_DIR: the draft is destined for the public inbox and an # absolute path is a home-directory leak the pre-commit guard refuses (caught 2026-09-14). - printf -- '- bin/%s — STALE: identical to shipped %s (%s); fix: bin/sync-project.sh --upgrade-bin %s\n\n' \ - "$base" "$MATCH_SHORT" "$MATCH_DATE" "$base" >> "$TMP" + printf -- '- bin/%s — STALE: identical to shipped %s (%s); fix: bin/sync-project.sh --only-bin %s --upgrade-bin %s\n\n' \ + "$base" "$MATCH_SHORT" "$MATCH_DATE" "$base" "$base" >> "$TMP" continue fi diff --git a/bin/sync-project.sh b/bin/sync-project.sh index 513c0810..e56103df 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -12,6 +12,14 @@ # A locally modified script is REPORTED, never overwritten, unless --upgrade-bin names it # (and then the local copy is backed up first). Six of this machine's projects have # hand-hardened their exec.sh; a blind-overwrite sync would have destroyed all six. +# --upgrade-bin is NOT a scoped install: it only lifts that refusal, and the FULL sync +# below still runs (skills, CLAUDE.md, hooks, tests/e2e, deletions). +# - --only-bin X [--only-bin Y]: the scoped install. Copies ONLY the named project-bin/ +# scripts into /bin/, prints each file written, and touches nothing else — no +# section below runs. Same refusal for a locally modified copy (unless --upgrade-bin names +# it, or --force), and an unknown name exits 1 before anything is written. Field run, +# issue #390: an "--upgrade-bin exec.sh" install changed 244 tracked files, deleted one, +# added ~30 and reinstalled the git hooks. # - tests/e2e/ engine: same three-way contract as bin/ (missing / unedited older copy / # locally modified). --no-refresh turns the middle case back into a report for both. # - triage.md: installs it if the project predates the Stage 0 scaffold, refreshes it only @@ -83,6 +91,17 @@ AGENTS="$MXTK_AGENTS" # "Is this copy an unedited older toolkit version?" — the test that lets sections 4 and 4b # refresh a stale bin/ or tests/e2e/ copy without a flag. See the file's header. . "$SCRIPT_DIR/lib/past-stock.sh" +TK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# copy_kind — classify a project copy that exists: +# "same", "stock:" (an unedited older toolkit version), or "edited". +# Defined up here because --only-bin (which exits before any section runs) classifies too. +copy_kind() { + if cmp -s "$2" "$3"; then echo same; return; fi + # project-bin/X was bin/X before the project-bin split; its older versions live there. + _ck_when="$(mxtk_past_stock "$TK_ROOT" "$1" "$3" "bin/${1##*/}")" && { echo "stock:$_ck_when"; return; } + echo edited +} # Is this agent file an UNTOUCHED stub (safe to overwrite), or completed work? # Two conditions, both required: it still carries the STUB GENERATED banner AND it still @@ -128,6 +147,8 @@ w_mkdir() { } STRICT=0 UPGRADE_BIN="" +ONLY_BIN="" +FORCE=0 UPGRADE_LINT="" REPAIR_INTAKE=0 ADOPT_ROUTING=0 @@ -136,7 +157,9 @@ NO_REFRESH=0 PROJECT_DIR="" USAGE="Usage: $0 [--diff-completed] [--dry-run] [--strict] [--upgrade-bin ] [--upgrade-lint-rules ] - [--repair-intake] [--adopt-routing] [--pin-models] [--no-refresh]" + [--repair-intake] [--adopt-routing] [--pin-models] [--no-refresh] + $0 --only-bin [--only-bin ...] [--upgrade-bin ] + [--force] [--dry-run] [--no-refresh]" while [ $# -gt 0 ]; do case "$1" in --diff-completed) DIFF_COMPLETED=1; shift ;; @@ -146,6 +169,11 @@ while [ $# -gt 0 ]; do --adopt-routing) ADOPT_ROUTING=1; shift ;; --pin-models) PIN_MODELS=1; shift ;; --no-refresh) NO_REFRESH=1; shift ;; + --force) FORCE=1; shift ;; + --only-bin) + shift + [ $# -gt 0 ] || { echo "--only-bin needs a script name from the toolkit's project-bin/" >&2; exit 1; } + ONLY_BIN="$ONLY_BIN $1"; shift ;; --upgrade-bin) shift [ $# -gt 0 ] || { echo "--upgrade-bin needs a script name or 'all'" >&2; exit 1; } @@ -171,6 +199,13 @@ while [ $# -gt 0 ]; do echo " --strict exit 3 if any warning was emitted (for CI / gate-check)" echo " --upgrade-bin X accept the toolkit version of a LOCALLY MODIFIED bin/ crash-net" echo " script (or 'all'). The project copy is backed up first." + echo " NOT a scoped install: the full sync still runs. To install" + echo " just some scripts, use --only-bin." + echo " --only-bin X copy ONLY project-bin/X into /bin/ (repeatable) and" + echo " nothing else: no skills, CLAUDE.md, hooks, tests or deletions." + echo " A locally modified copy is refused unless --upgrade-bin names" + echo " it or --force is given (backed up first either way)." + echo " --force with --only-bin: overwrite locally modified copies too." echo " --upgrade-lint-rules X accept the toolkit version of a LOCALLY MODIFIED" echo " .claude/lint-rules/ rule (or 'all'), backing the copy up first." echo " --repair-intake accept the offered repair of a stale, still-verbatim intake" @@ -195,6 +230,13 @@ if [ -z "$PROJECT_DIR" ]; then echo "$USAGE" >&2 exit 1 fi +if [ -z "$ONLY_BIN" ]; then + [ "$FORCE" -eq 0 ] || { echo "--force only applies to --only-bin" >&2; exit 1; } +elif [ "$DIFF_COMPLETED$REPAIR_INTAKE$ADOPT_ROUTING$PIN_MODELS" != "0000" ] || [ -n "$UPGRADE_LINT" ]; then + echo "--only-bin installs bin/ scripts and nothing else; it cannot be combined with" >&2 + echo "--diff-completed, --repair-intake, --adopt-routing, --pin-models or --upgrade-lint-rules." >&2 + exit 1 +fi if [ ! -d "$PROJECT_DIR" ]; then echo "Error: project directory does not exist: $PROJECT_DIR" >&2 exit 1 @@ -202,6 +244,63 @@ fi PROJECT_NAME="$(basename "$(cd "$PROJECT_DIR" && pwd)")" +# --- --only-bin: the scoped install (issue #390) ------------------------------------------ +# Everything is decided before anything is written: an unknown name or a refused overwrite +# exits 1 with the project untouched. bin/ is /bin on both layouts (single-tree, +# and two-tree with the .mpr under app/) — the same place the crash-net section writes. +if [ -n "$ONLY_BIN" ]; then + OB_SRC="$TK_ROOT/project-bin" + OB_DST="$PROJECT_DIR/bin" + ob_wired=0 + for m in bin PROJECT.md intake.md CLAUDE.local.md; do [ -e "$PROJECT_DIR/$m" ] && ob_wired=1; done + [ "$ob_wired" -eq 1 ] || { echo "Error: $PROJECT_DIR has no PROJECT.md, intake.md, CLAUDE.local.md or bin/ — not a wired project. Nothing written." >&2; exit 1; } + ob_err=0; ob_plan="" + for s in $ONLY_BIN; do + case "$s" in */*|.*|"") echo "Error: --only-bin takes a bare file name from project-bin/, got '$s'" >&2; ob_err=1; continue ;; esac + src="$OB_SRC/$s"; dst="$OB_DST/$s" + if [ ! -f "$src" ]; then echo "Error: no such script in the toolkit's project-bin/: $s" >&2; ob_err=1; continue; fi + if [ ! -f "$dst" ]; then ob_plan="$ob_plan install:$s"; continue; fi + kind="$(copy_kind "project-bin/$s" "$src" "$dst")" + if [ "$kind" = same ]; then ob_plan="$ob_plan same:$s"; continue; fi + if [ "${kind#stock:}" != "$kind" ] && [ "$NO_REFRESH" -eq 0 ]; then ob_plan="$ob_plan refresh:$s"; continue; fi + if [ "$FORCE" -eq 1 ] || case " $UPGRADE_BIN " in *" all "*|*" $s "*) true ;; *) false ;; esac; then + ob_plan="$ob_plan upgrade:$s"; continue + fi + echo "Refused: bin/$s is LOCALLY MODIFIED. See what differs: diff -u $src $dst" >&2 + echo " Accept the toolkit version (your copy is backed up): add --upgrade-bin $s (or --force)" >&2 + ob_err=1 + done + [ "$ob_err" -eq 0 ] || { echo "--only-bin: nothing written." >&2; exit 1; } + echo "=== Scoped bin/ install for $PROJECT_DIR (--only-bin; nothing else is touched) ===" + [ -d "$OB_DST" ] || w_mkdir "$OB_DST" + ob_n=0 + for item in $ob_plan; do + act="${item%%:*}"; s="${item#*:}"; src="$OB_SRC/$s"; dst="$OB_DST/$s" + if [ "$act" = same ]; then + if [ -x "$src" ] && [ ! -x "$dst" ]; then + if [ "$DRY_RUN" -eq 1 ]; then echo "Would repair exec bit: bin/$s"; else chmod +x "$dst"; echo "Repaired exec bit: bin/$s"; fi + ob_n=$((ob_n + 1)) + else + echo "Unchanged: bin/$s (already the toolkit version)" + fi + continue + fi + if [ "$DRY_RUN" -eq 1 ]; then echo "Would write: bin/$s ($act)"; ob_n=$((ob_n + 1)); continue; fi + if [ "$act" = upgrade ]; then + bak="$dst.local-$(date +%Y%m%d%H%M%S)" + cp "$dst" "$bak"; chmod -x "$bak" + echo "Wrote: bin/$(basename "$bak") (backup of your locally modified copy)" + ob_n=$((ob_n + 1)) + fi + cp "$src" "$dst" + if [ -x "$src" ]; then chmod +x "$dst"; fi + echo "Wrote: bin/$s ($act)" + ob_n=$((ob_n + 1)) + done + echo "--only-bin: $ob_n file(s) $( [ "$DRY_RUN" -eq 1 ] && echo 'would be written' || echo 'written'); nothing else was touched." + exit 0 +fi + # --- 0. --diff-completed: the reverse path ---------------------------------------------- # Sync only ever flowed downhill. A completed agent was cut off from its template in BOTH # directions: the project never saw template improvements, and the toolkit never saw what @@ -969,16 +1068,6 @@ CRASHNET_SRC="$(cd "$SCRIPT_DIR/.." && pwd)/project-bin" # on — not crash-net per se, but "missing -> install, drifted -> report, never # blind-overwrite" applies identically to a project's tuned copy of either. CRASHNET_FILES="$MXTK_PROJECT_BIN" -TK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -# copy_kind — classify a project copy that exists: -# "same", "stock:" (an unedited older toolkit version), or "edited". -copy_kind() { - if cmp -s "$2" "$3"; then echo same; return; fi - # project-bin/X was bin/X before the project-bin split; its older versions live there. - _ck_when="$(mxtk_past_stock "$TK_ROOT" "$1" "$3" "bin/${1##*/}")" && { echo "stock:$_ck_when"; return; } - echo edited -} # The refresh hold: a hand-edited shared library keeps every older copy that depends on it as # it is (reported), because a refreshed caller on an edited library has never been run. diff --git a/project-bin/app-restart.sh b/project-bin/app-restart.sh index 9fc69e81..4126cad8 100755 --- a/project-bin/app-restart.sh +++ b/project-bin/app-restart.sh @@ -143,7 +143,7 @@ mask() { # print argv joined, with the value after any password/secret/token/ke # with the remedy, instead of dying mid-restart on "command not found". command -v mxtk_port_holder >/dev/null 2>&1 || { echo "app-restart: bin/_common.sh is older than this script (no mxtk_port_holder)." >&2 - echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $PROJECT_ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } # blocked "|" — exit 4, the holder named, nothing of it touched. blocked() { echo "✗ app-restart: instrument blocked — port :$1 is held by $(mxtk_holder_text "$2"), not this project's run --local." >&2 @@ -160,7 +160,7 @@ blocked() { # _common.sh that predates them. command -v mxtest_left >/dev/null 2>&1 && command -v mxtk_pid_is_mxcli_run >/dev/null 2>&1 || { echo "app-restart: bin/_common.sh is older than this script (no mxtest_left / mxtk_pid_is_mxcli_run)." >&2 - echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $PROJECT_ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } # in_our_tree — the recorded pid itself, or a descendant of it (the runtime mxcli started). in_our_tree() { [ "$ALIVE" = 1 ] && mxtk_pid_in_tree "$1" "$PID"; } diff --git a/project-bin/test-scratch.sh b/project-bin/test-scratch.sh index 09f019fe..9b1b5663 100755 --- a/project-bin/test-scratch.sh +++ b/project-bin/test-scratch.sh @@ -67,7 +67,7 @@ else echo "✗ test-scratch: no mxcli (project root or PATH)." >&2; exit 3; fi # "no injection left" and copying a model that recurses in its after-startup. command -v mxtest_left >/dev/null 2>&1 && command -v mxtest_refusal >/dev/null 2>&1 || { echo "test-scratch: bin/_common.sh is older than this script (no mxtest_left / mxtest_refusal)." >&2 - echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $PROJECT_ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } # A model whose after-startup is an mxcli test injection must not be copied and tested: the copy # carries it, and mxcli test then chains the injection to itself (#244 observation 1). diff --git a/project-bin/test-stack-up.sh b/project-bin/test-stack-up.sh index 09f7f009..55208809 100755 --- a/project-bin/test-stack-up.sh +++ b/project-bin/test-stack-up.sh @@ -160,7 +160,7 @@ esac # the remedy instead of dying mid-run on "command not found". command -v mxtk_pid_runs_deployment >/dev/null 2>&1 || { echo "test-stack-up: bin/_common.sh is older than this script (no mxtk_pid_runs_deployment)." >&2 - echo " → /bin/sync-project.sh $ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } + echo " → /bin/sync-project.sh $ROOT --only-bin _common.sh --upgrade-bin _common.sh (installs just that file; backs up a hand-edited copy first)" >&2; exit 2; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; } diff --git a/tests/wave2/test-sync-only-bin.sh b/tests/wave2/test-sync-only-bin.sh new file mode 100755 index 00000000..040efd4c --- /dev/null +++ b/tests/wave2/test-sync-only-bin.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Fixture for sync-project.sh --only-bin, the scoped install (issue #390, field finding 1). +# +# The field run that prompted it: "sync-project.sh --upgrade-bin exec.sh ..." was +# handed out as "install just these scripts". --upgrade-bin only lifts the overwrite refusal; +# the full sync still ran and changed 244 tracked files, deleted one, added ~30 and +# reinstalled the git hooks. --only-bin must write the named bin/ scripts and NOTHING else. +# +# usage: test-sync-only-bin.sh /path/to/sync-project.sh +# +# Each case runs on BOTH layouts (single-tree, and two-tree with the .mpr under app/): +# O1 --only-bin restore-mpr.sh on a git-committed project: git status shows exactly one +# change (bin/restore-mpr.sh), although a full sync would have done more (control) +# O2 hooks are not touched (removed hooks stay removed; a full sync reinstalls them) +# O3 skills are not touched, and the whole tree outside bin/ is byte-identical +# O4 an unknown script exits non-zero and writes nothing; so does a path-like name +# O5 a locally modified copy is refused (exit 1, nothing written), accepted with +# --upgrade-bin or --force, backed up first, and every written file is printed +# +# Every fixture is built by the REAL bin/init-project.sh under /tmp. No real project is touched. +export MXTK_LEAKGUARD_DENYFILE="${TMPDIR:-/tmp}/mxtk-fixture-denylist.$$" +export MXTK_SYNC_SKIP_CLONE_CHECK=1 +set -uo pipefail + +SYNC="${1:?usage: test-sync-only-bin.sh /path/to/sync-project.sh}" +case "$SYNC" in /*) ;; *) SYNC="$PWD/$SYNC" ;; esac +TK="$(cd "$(dirname "$SYNC")/.." && pwd)" +INIT="$TK/bin/init-project.sh" +WORK="$(mktemp -d /tmp/onlybin.XXXXXX)" +trap 'rm -f "$MXTK_LEAKGUARD_DENYFILE"; rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf '%s\n' "$2" | sed 's/^/ FAIL-detail: /'; } + +# Path, exec bit and content hash of every file, .git/hooks included (that is where the +# reinstall lands) but the rest of .git excluded (index stat noise). $2 = a path to leave out. +fingerprint() { + ( cd "$1" && find . -type f ! -path './.git/*' -o -type f -path './.git/hooks/*' ) 2>/dev/null \ + | LC_ALL=C sort | while read -r f; do + [ "$f" = "./${2:-//none}" ] && continue + if [ -x "$1/$f" ]; then x=x; else x=-; fi + printf '%s %s %s\n' "$f" "$x" "$(md5 -q "$1/$f" 2>/dev/null || md5sum "$1/$f" | cut -d' ' -f1)" + done +} + +# A committed project in which a FULL sync has work to do: two bin/ scripts missing, the git +# hooks removed, and a skills tree a sync must not touch. +mkproj() { + d="$WORK/$1-$2"; mkdir -p "$d" + if [ "$2" = two ]; then mkdir -p "$d/app"; : > "$d/app/Fixture.mpr"; else : > "$d/Fixture.mpr"; fi + git -C "$d" init -q + MXTK_NO_GUIDE=1 "$INIT" "$d" >/dev/null 2>&1 + mkdir -p "$d/.claude/skills/demo" "$d/.ai-context/skills" + echo "# demo skill" > "$d/.claude/skills/demo/SKILL.md" + echo "# local skill" > "$d/.ai-context/skills/local.md" + rm -f "$d/bin/restore-mpr.sh" "$d/bin/snapshot-mpr.sh" + find "$d/.git/hooks" -type f ! -name '*.sample' -exec rm -f {} + + git -C "$d" add -A >/dev/null 2>&1 + git -C "$d" -c user.name=fx -c user.email=t@t commit -qm fixture --no-verify >/dev/null 2>&1 + echo "$d" +} +# git status lines (ignored files included) that differ from the snapshot $2 — what a run changed. +gst() { git -C "$1" status --porcelain --ignored | LC_ALL=C sort; } +st_delta() { gst "$1" | LC_ALL=C comm -3 - <(printf '%s\n' "$2"); } +hooks_of() { find "$1/.git/hooks" -type f ! -name '*.sample' | LC_ALL=C sort; } + +for L in single two; do + echo "== layout: $L ==" + P="$(mkproj o1 "$L")" + B="$(fingerprint "$P" bin/restore-mpr.sh)"; S0="$(gst "$P")" + OUT="$("$SYNC" "$P" --only-bin restore-mpr.sh 2>&1)"; RC=$? + ST="$(st_delta "$P" "$S0")" + [ "$RC" -eq 0 ] && ok "O1[$L] exit 0" || bad "O1[$L] exit $RC" "$OUT" + [ "$ST" = "?? bin/restore-mpr.sh" ] && ok "O1[$L] git status shows exactly bin/restore-mpr.sh" \ + || bad "O1[$L] git status is not exactly one change" "$ST" + cmp -s "$TK/project-bin/restore-mpr.sh" "$P/bin/restore-mpr.sh" && [ -x "$P/bin/restore-mpr.sh" ] \ + && ok "O1[$L] bin/restore-mpr.sh is the toolkit copy, executable" || bad "O1[$L] restore-mpr.sh not installed" + printf '%s' "$OUT" | grep -q '^Wrote: bin/restore-mpr.sh' && printf '%s' "$OUT" | grep -q '1 file(s) written' \ + && ok "O1[$L] prints the file written" || bad "O1[$L] written file not printed" "$OUT" + [ -z "$(hooks_of "$P")" ] && ok "O2[$L] hooks not touched (still absent)" || bad "O2[$L] hooks were installed" "$(hooks_of "$P")" + [ "$B" = "$(fingerprint "$P" bin/restore-mpr.sh)" ] && ok "O3[$L] skills and every other file byte-identical" \ + || bad "O3[$L] something besides bin/restore-mpr.sh changed" "$(diff <(echo "$B") <(fingerprint "$P" bin/restore-mpr.sh))" + + # Control: the same project under the old instruction does far more — the fixture discriminates. + "$SYNC" "$P" --upgrade-bin restore-mpr.sh >/dev/null 2>&1 + { [ -f "$P/bin/snapshot-mpr.sh" ] && [ -n "$(hooks_of "$P")" ]; } \ + && ok "control[$L] a full --upgrade-bin sync installs other scripts and the hooks" \ + || bad "control[$L] the full sync did not do more — O1-O3 prove nothing" + + P="$(mkproj o4 "$L")"; B="$(fingerprint "$P")" + OUT="$("$SYNC" "$P" --only-bin restore-mpr.sh --only-bin no-such-script.sh 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q 'no-such-script.sh' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O4[$L] unknown script: exit $RC, named, nothing written (not even the valid one)" \ + || bad "O4[$L] unknown script: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin ../bin/gate-check.sh 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && [ "$B" = "$(fingerprint "$P")" ] && ok "O4[$L] path-like name refused" || bad "O4[$L] path-like name: rc=$RC" "$OUT" + + P="$(mkproj o5 "$L")" + printf '#!/usr/bin/env bash\n# LOCAL HARDENING\nexit 0\n' > "$P/bin/exec.sh" + git -C "$P" -c user.name=fx -c user.email=t@t commit -qam local --no-verify >/dev/null 2>&1 + B="$(fingerprint "$P")"; S0="$(gst "$P")" + OUT="$("$SYNC" "$P" --only-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 1 ] && printf '%s' "$OUT" | grep -q 'LOCALLY MODIFIED' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O5[$L] locally modified copy refused, nothing written" || bad "O5[$L] refusal: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin exec.sh --upgrade-bin exec.sh 2>&1)"; RC=$? + BAK="$(find "$P/bin" -name 'exec.sh.local-*' | head -1)" + { [ "$RC" -eq 0 ] && cmp -s "$TK/project-bin/exec.sh" "$P/bin/exec.sh" && [ -n "$BAK" ] \ + && grep -q 'LOCAL HARDENING' "$BAK" && printf '%s' "$OUT" | grep -q '2 file(s) written'; } \ + && ok "O5[$L] --upgrade-bin names it: upgraded, backed up, both files printed" || bad "O5[$L] --upgrade-bin: rc=$RC" "$OUT" + ST="$(st_delta "$P" "$S0")" + { [ "$(printf '%s\n' "$ST" | wc -l | tr -d ' ')" = 2 ] && printf '%s' "$ST" | grep -q '^ M bin/exec.sh$' \ + && printf '%s' "$ST" | grep -q '^?? bin/exec.sh.local-'; } \ + && ok "O5[$L] git status: exec.sh + its backup, nothing else" || bad "O5[$L] extra changes" "$ST" + printf '#!/usr/bin/env bash\n# LOCAL AGAIN\n' > "$P/bin/exec.sh" + "$SYNC" "$P" --only-bin exec.sh --force >/dev/null 2>&1 && cmp -s "$TK/project-bin/exec.sh" "$P/bin/exec.sh" \ + && ok "O5[$L] --force overwrites (after a backup)" || bad "O5[$L] --force did not overwrite" +done + +echo "" +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] From 126fbd8c7db21c0f90c60a5192681626c5c7877a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:23:14 +0000 Subject: [PATCH 2/2] fix(sync-project): --only-bin honours the refresh hold and the manifest Review fixes for #396: - an unedited older copy is not refreshed on top of a hand-edited bin/_common.sh (the full sync's hold, same message) unless --upgrade-bin names it / all, or --force - names are validated against MXTK_PROJECT_BIN; NOINSTALL files such as report-disposition-check.sh are refused - set -f around the --only-bin name and plan loops: '*' is never globbed - --only-bin '' (or a value with spaces) is a usage error - the dry-run line for an upgrade says a backup would be written Fixture test-sync-only-bin.sh: 40/40 (O6-O9 added, both layouts; the previous commit fails 12 of them). test-bug12-sync.sh: 61/61. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 2 +- bin/sync-project.sh | 53 ++++++++++++++++++++++++++++--- tests/wave2/test-sync-only-bin.sh | 44 +++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a52fb3c3..c15f2f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- new(sync-project): **`bin/sync-project.sh --only-bin [--only-bin …]` installs only the named `project-bin/` scripts into the project's `bin/` and touches nothing else; `--upgrade-bin` is now documented as what it is — a full sync that may also overwrite a modified copy.** A field run used `--upgrade-bin exec.sh --upgrade-bin _common.sh --upgrade-bin test-scratch.sh` as "install just these scripts" and got a full sync: 244 tracked files changed, one deleted, ~30 added, git hooks reinstalled. `--only-bin` decides everything before writing anything (an unknown or path-like name, or a locally modified copy without `--upgrade-bin `/`--force`, exits 1 with the project untouched), backs up an overwritten copy, and prints every file it wrote. The remedy lines in `app-restart.sh`, `test-scratch.sh`, `test-stack-up.sh`, `doctor.sh` and `harvest-learnings.sh` now name `--only-bin`. Fixture `tests/wave2/test-sync-only-bin.sh` (26 checks, single- and two-tree): one `git status` change, hooks and skills untouched, while a full `--upgrade-bin` sync of the same project installs other scripts and the hooks. — field run, issue #390 +- new(sync-project): **`bin/sync-project.sh --only-bin [--only-bin …]` installs only the named `project-bin/` scripts into the project's `bin/` and touches nothing else; `--upgrade-bin` is now documented as what it is — a full sync that may also overwrite a modified copy.** A field run used `--upgrade-bin exec.sh --upgrade-bin _common.sh --upgrade-bin test-scratch.sh` as "install just these scripts" and got a full sync: 244 tracked files changed, one deleted, ~30 added, git hooks reinstalled. `--only-bin` decides everything before writing anything (a name outside the install manifest, NOINSTALL scripts included, a path-like or empty name, a locally modified copy without `--upgrade-bin `/`--force`, or an older copy the full sync's `_common.sh` refresh hold would keep, exits 1 with the project untouched; names are never glob-expanded), backs up an overwritten copy, and prints every file it wrote. The remedy lines in `app-restart.sh`, `test-scratch.sh`, `test-stack-up.sh`, `doctor.sh` and `harvest-learnings.sh` now name `--only-bin`. Fixture `tests/wave2/test-sync-only-bin.sh` (40 checks, single- and two-tree): one `git status` change, hooks and skills untouched, while a full `--upgrade-bin` sync of the same project installs other scripts and the hooks. — field run, issue #390 - new(tests/behaviour): **a standing behaviour suite for agent-read text: arms M (base ref), T (candidate ref, or the base minus one skill for an ablation) and N (no pack), two drafting tasks with planted STOP-row requests and five retrieval probes, scored mechanically.** `tests/behaviour/build-packs.sh` builds a pack from any ref in a temporary worktree; `score.sh` adds `read_all` (the agent quoted the pack's `PACK-END` line back), `tool_calls` and `tokens`, and treats `mxcli check` as optional (NA without a binary). `RUNBOOK.md` has the triggers (every PR that changes baseline-routed text or an agent stub; N and ablations at each release cut), 5 reps x 2 tasks plus 5 probes x 3 reps, the rule (T no worse than M on every scored metric, probes T >= M - 1), and partial reads counted and re-run, never dropped. `docs/RELEASE-PROCESS.md` §4a names it as the way to run the Behaviour A/B. Field runs committed under `tests/behaviour/results/`: the 2026-10-15 stub-diet A/B (#369/#378/#380, 42 reps, 3 partial reads on the 4,822-line pack) and the first no-pack run (page trap refused 0/5 without a pack against 5/5 with either; mf passes check 1/5 against 5/5; every arm, N included, also inherited the host session's project CLAUDE.md (a generic mxcli/MDL guide), so N measures 'no toolkit rules', not 'no guidance'). Golden input: four real agent outputs, `tests/wave2/test-behaviour-score.sh`. — maintainer request (MendixMau) - process(pr-template, release-process): **every Direct-lane PR and every review fills a five-row Impact block: Process, Dependencies, Context cost, Behaviour, Runtime.** "none" or "NOT RUN: " is legal, a blank row is not. Context cost is pasted from `bin/render-routing.sh --check` on base and head, never estimated; a missing or estimated row is a review finding. The Behaviour A/B (`process/preflight-skill-baseline-2026-08-31.md`) is required for baseline-routed text and agent stubs, recommended for situational skills, NOT RUN with a reason for process docs review agents read, n/a for scripts only; Context cost may be "none" when no routed or always-loaded file is touched. `docs/RELEASE-PROCESS.md` §4a holds the rule and a bad/good example; `CONTRIBUTING.md`'s reviewer list points there. No CI check: `bin/check-pr-discipline.sh` reads the git diff, not the PR body. Field evidence: three PRs reviewed on 2026-10-15 with no process cost stated (#375 added a human step per re-baseline, #384 adds 1–11 s per exec), both found by chance. — maintainer request, MendixMau - learn(learned-detection-gaps, testing-shape, ui-loop): **a class on a snippet call never reaches the DOM; a 403 bounce and a widget-pinned session probe both read as sign-outs; app restarts matched a "signs me out" report by timing.** Detection-gaps row: `Class` on a `SNIPPETCALL` is green through check, exec, mxbuild, lint and DESCRIBE, and absent in the browser ([#336](https://github.com/MendixMau/mxcli-project-toolkit/issues/336)). Testing-shape §4 gains two false-red rows: `login.html?messageCode=403` with the session kept ([#338](https://github.com/MendixMau/mxcli-project-toolkit/issues/338)), and instruments pinned to one grid name or a typed menu list (1/7 and 7/34 false FAILs after a redesign, [#339](https://github.com/MendixMau/mxcli-project-toolkit/issues/339)). ui-loop: say so before a restart while a human uses the app ([#337](https://github.com/MendixMau/mxcli-project-toolkit/issues/337)) — three-app field run, 2026-10-14 diff --git a/bin/sync-project.sh b/bin/sync-project.sh index e56103df..1770bc32 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -173,6 +173,9 @@ while [ $# -gt 0 ]; do --only-bin) shift [ $# -gt 0 ] || { echo "--only-bin needs a script name from the toolkit's project-bin/" >&2; exit 1; } + # One bare name per flag. An empty or space-holding value would split into nothing (and + # "0 file(s) written", exit 0) or into several names the caller never typed. + case "$1" in ""|*[[:space:]]*) echo "--only-bin needs ONE script name, got '$1'" >&2; exit 1 ;; esac ONLY_BIN="$ONLY_BIN $1"; shift ;; --upgrade-bin) shift @@ -248,32 +251,63 @@ PROJECT_NAME="$(basename "$(cd "$PROJECT_DIR" && pwd)")" # Everything is decided before anything is written: an unknown name or a refused overwrite # exits 1 with the project untouched. bin/ is /bin on both layouts (single-tree, # and two-tree with the .mpr under app/) — the same place the crash-net section writes. +# Names must be in the install manifest ($MXTK_PROJECT_BIN): a file that sits in project-bin/ +# but is deliberately not installed (MXTK_PROJECT_BIN_NOINSTALL) is refused like any other. +# The full sync's refresh hold applies too: an unedited older copy is NOT refreshed on top of a +# hand-edited bin/_common.sh unless --upgrade-bin names it (or all) or --force is given. +# `set -f` while splitting the name lists: `--only-bin '*'` must never glob into the cwd. if [ -n "$ONLY_BIN" ]; then OB_SRC="$TK_ROOT/project-bin" OB_DST="$PROJECT_DIR/bin" ob_wired=0 for m in bin PROJECT.md intake.md CLAUDE.local.md; do [ -e "$PROJECT_DIR/$m" ] && ob_wired=1; done [ "$ob_wired" -eq 1 ] || { echo "Error: $PROJECT_DIR has no PROJECT.md, intake.md, CLAUDE.local.md or bin/ — not a wired project. Nothing written." >&2; exit 1; } - ob_err=0; ob_plan="" + ob_err=0; ob_plan=""; ob_hold="" + if [ "$NO_REFRESH" -eq 0 ] && [ -f "$OB_DST/_common.sh" ] && [ -f "$OB_SRC/_common.sh" ] \ + && [ "$(copy_kind project-bin/_common.sh "$OB_SRC/_common.sh" "$OB_DST/_common.sh")" = edited ]; then + ob_hold="_common.sh" + fi + set -f for s in $ONLY_BIN; do case "$s" in */*|.*|"") echo "Error: --only-bin takes a bare file name from project-bin/, got '$s'" >&2; ob_err=1; continue ;; esac src="$OB_SRC/$s"; dst="$OB_DST/$s" if [ ! -f "$src" ]; then echo "Error: no such script in the toolkit's project-bin/: $s" >&2; ob_err=1; continue; fi + case " $MXTK_PROJECT_BIN " in *" $s "*) ;; *) + case " $MXTK_PROJECT_BIN_NOINSTALL " in + *" $s "*) echo "Error: $s is deliberately not installed into projects (MXTK_PROJECT_BIN_NOINSTALL in bin/lib/install-manifest.sh)" >&2 ;; + *) echo "Error: $s is not in the install manifest (MXTK_PROJECT_BIN in bin/lib/install-manifest.sh)" >&2 ;; + esac + ob_err=1; continue ;; + esac if [ ! -f "$dst" ]; then ob_plan="$ob_plan install:$s"; continue; fi kind="$(copy_kind "project-bin/$s" "$src" "$dst")" if [ "$kind" = same ]; then ob_plan="$ob_plan same:$s"; continue; fi - if [ "${kind#stock:}" != "$kind" ] && [ "$NO_REFRESH" -eq 0 ]; then ob_plan="$ob_plan refresh:$s"; continue; fi if [ "$FORCE" -eq 1 ] || case " $UPGRADE_BIN " in *" all "*|*" $s "*) true ;; *) false ;; esac; then ob_plan="$ob_plan upgrade:$s"; continue fi - echo "Refused: bin/$s is LOCALLY MODIFIED. See what differs: diff -u $src $dst" >&2 - echo " Accept the toolkit version (your copy is backed up): add --upgrade-bin $s (or --force)" >&2 + if [ "${kind#stock:}" != "$kind" ]; then + if [ "$NO_REFRESH" -eq 0 ] && { [ -z "$ob_hold" ] || [ "$s" = "$ob_hold" ]; }; then + ob_plan="$ob_plan refresh:$s"; continue + fi + if [ -n "$ob_hold" ] && [ "$NO_REFRESH" -eq 0 ]; then + echo "⚠️ bin/$s is an unedited toolkit copy of ${kind#stock:}, NOT refreshed: bin/$ob_hold is" >&2 + echo " locally edited and $s depends on it. Accept both together: $0 $PROJECT_DIR --upgrade-bin all" >&2 + echo " Scoped: $0 $PROJECT_DIR --only-bin $ob_hold --only-bin $s --upgrade-bin $ob_hold --upgrade-bin $s" >&2 + else + echo "Refused: bin/$s is an unedited toolkit copy of ${kind#stock:} and --no-refresh keeps it." >&2 + fi + else + echo "Refused: bin/$s is LOCALLY MODIFIED. See what differs: diff -u $src $dst" >&2 + echo " Accept the toolkit version (your copy is backed up): add --upgrade-bin $s (or --force)" >&2 + fi ob_err=1 done + set +f [ "$ob_err" -eq 0 ] || { echo "--only-bin: nothing written." >&2; exit 1; } echo "=== Scoped bin/ install for $PROJECT_DIR (--only-bin; nothing else is touched) ===" [ -d "$OB_DST" ] || w_mkdir "$OB_DST" ob_n=0 + set -f for item in $ob_plan; do act="${item%%:*}"; s="${item#*:}"; src="$OB_SRC/$s"; dst="$OB_DST/$s" if [ "$act" = same ]; then @@ -285,7 +319,15 @@ if [ -n "$ONLY_BIN" ]; then fi continue fi - if [ "$DRY_RUN" -eq 1 ]; then echo "Would write: bin/$s ($act)"; ob_n=$((ob_n + 1)); continue; fi + if [ "$DRY_RUN" -eq 1 ]; then + if [ "$act" = upgrade ]; then + echo "Would upgrade: bin/$s — local copy would be backed up first (bin/$s.local-)" + ob_n=$((ob_n + 2)) + else + echo "Would write: bin/$s ($act)"; ob_n=$((ob_n + 1)) + fi + continue + fi if [ "$act" = upgrade ]; then bak="$dst.local-$(date +%Y%m%d%H%M%S)" cp "$dst" "$bak"; chmod -x "$bak" @@ -297,6 +339,7 @@ if [ -n "$ONLY_BIN" ]; then echo "Wrote: bin/$s ($act)" ob_n=$((ob_n + 1)) done + set +f echo "--only-bin: $ob_n file(s) $( [ "$DRY_RUN" -eq 1 ] && echo 'would be written' || echo 'written'); nothing else was touched." exit 0 fi diff --git a/tests/wave2/test-sync-only-bin.sh b/tests/wave2/test-sync-only-bin.sh index 040efd4c..e4494e94 100755 --- a/tests/wave2/test-sync-only-bin.sh +++ b/tests/wave2/test-sync-only-bin.sh @@ -16,6 +16,12 @@ # O4 an unknown script exits non-zero and writes nothing; so does a path-like name # O5 a locally modified copy is refused (exit 1, nothing written), accepted with # --upgrade-bin or --force, backed up first, and every written file is printed +# O6 the full sync's refresh hold: an unedited OLDER exec.sh is refreshed on a clean +# _common.sh (control) but refused on a hand-edited one, unless --upgrade-bin names it; +# --dry-run of that upgrade says a backup would be written, and writes nothing +# O7 a project-bin/ file outside the install manifest (NOINSTALL) is refused +# O8 --only-bin '*' from a cwd full of script names installs nothing (no glob expansion) +# O9 --only-bin '' is a usage error # # Every fixture is built by the REAL bin/init-project.sh under /tmp. No real project is touched. export MXTK_LEAKGUARD_DENYFILE="${TMPDIR:-/tmp}/mxtk-fixture-denylist.$$" @@ -25,6 +31,8 @@ set -uo pipefail SYNC="${1:?usage: test-sync-only-bin.sh /path/to/sync-project.sh}" case "$SYNC" in /*) ;; *) SYNC="$PWD/$SYNC" ;; esac TK="$(cd "$(dirname "$SYNC")/.." && pwd)" +# O6 needs an older exec.sh the toolkit really shipped (CI checks out full history). +OLD_EXEC_REV="$(git -C "$TK" log --format=%H -n2 -- project-bin/exec.sh 2>/dev/null | sed -n 2p)" INIT="$TK/bin/init-project.sh" WORK="$(mktemp -d /tmp/onlybin.XXXXXX)" trap 'rm -f "$MXTK_LEAKGUARD_DENYFILE"; rm -rf "$WORK"' EXIT @@ -114,6 +122,42 @@ for L in single two; do printf '#!/usr/bin/env bash\n# LOCAL AGAIN\n' > "$P/bin/exec.sh" "$SYNC" "$P" --only-bin exec.sh --force >/dev/null 2>&1 && cmp -s "$TK/project-bin/exec.sh" "$P/bin/exec.sh" \ && ok "O5[$L] --force overwrites (after a backup)" || bad "O5[$L] --force did not overwrite" + + P="$(mkproj o6 "$L")" + if [ -z "$OLD_EXEC_REV" ]; then + bad "O6[$L] no older project-bin/exec.sh in the toolkit's git history (shallow clone?)" + else + old_exec() { git -C "$TK" show "$OLD_EXEC_REV:project-bin/exec.sh" > "$P/bin/exec.sh"; chmod +x "$P/bin/exec.sh"; } + old_exec + OUT="$("$SYNC" "$P" --only-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q '^Wrote: bin/exec.sh (refresh)' \ + && ok "O6[$L] control: older unedited exec.sh refreshed on a clean _common.sh" || bad "O6[$L] control: rc=$RC" "$OUT" + old_exec; printf '# local hardening\n' >> "$P/bin/_common.sh" + git -C "$P" -c user.name=fx -c user.email=t@t commit -qam held --no-verify >/dev/null 2>&1 + B="$(fingerprint "$P")" + OUT="$("$SYNC" "$P" --only-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 1 ] && printf '%s' "$OUT" | grep -q 'NOT refreshed: bin/_common.sh is' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O6[$L] held on an edited _common.sh: refused with the full sync's message, nothing written" || bad "O6[$L] hold: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin exec.sh --upgrade-bin exec.sh --dry-run 2>&1)"; RC=$? + [ "$RC" -eq 0 ] && printf '%s' "$OUT" | grep -q 'local copy would be backed up first' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O6[$L] --dry-run upgrade names the backup and writes nothing" || bad "O6[$L] dry-run: rc=$RC" "$OUT" + OUT="$("$SYNC" "$P" --only-bin exec.sh --upgrade-bin exec.sh 2>&1)"; RC=$? + [ "$RC" -eq 0 ] && cmp -s "$TK/project-bin/exec.sh" "$P/bin/exec.sh" && [ -n "$(find "$P/bin" -name 'exec.sh.local-*')" ] \ + && ok "O6[$L] --upgrade-bin exec.sh lifts the hold (backed up)" || bad "O6[$L] upgrade: rc=$RC" "$OUT" + fi + + P="$(mkproj o7 "$L")"; B="$(fingerprint "$P")" + OUT="$("$SYNC" "$P" --only-bin report-disposition-check.sh 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q 'NOINSTALL' && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O7[$L] NOINSTALL script refused, nothing written" || bad "O7[$L] NOINSTALL: rc=$RC" "$OUT" + + G="$WORK/glob-$L"; mkdir -p "$G"; : > "$G/restore-mpr.sh"; : > "$G/snapshot-mpr.sh" + OUT="$(cd "$G" && "$SYNC" "$P" --only-bin '*' 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && [ ! -e "$P/bin/restore-mpr.sh" ] && [ ! -e "$P/bin/snapshot-mpr.sh" ] && [ "$B" = "$(fingerprint "$P")" ] \ + && ok "O8[$L] --only-bin '*' is not glob-expanded: refused, nothing written" || bad "O8[$L] glob: rc=$RC" "$OUT" + + OUT="$("$SYNC" "$P" --only-bin '' 2>&1)"; RC=$? + [ "$RC" -ne 0 ] && [ "$B" = "$(fingerprint "$P")" ] && ok "O9[$L] --only-bin '' is a usage error" || bad "O9[$L] empty name: rc=$RC" "$OUT" done echo ""