From ed21d1270007a8f9cbaa2ca7da0fee1eefeb405f Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Fri, 25 Sep 2026 12:20:13 +0300 Subject: link/pi.sh: the generators move into files, and parse under bash 3.2 again 57e49dd made link/pi.sh unparseable on macOS. bash 3.2 does not skip heredoc content when it scans for the end of a command substitution, so the three apostrophes in a comment inside the new heredoc read as an unterminated quote and the script stopped parsing at end of file. ./link.sh on a Mac aborted partway: settings still generated, models.json stayed the symlink the commit meant to replace, and everything after that point never ran. Linux was fine, and so was every check I ran -- `bash` on a Mac with Homebrew is 5.x, which parses it happily. Reported from the machine it broke. Rewording the comment would fix it and leave the trap armed for the next edit, so the two python programs move into link/pi-settings.py and link/pi-models.py instead, called by path. The text is unchanged; there is no longer a heredoc inside $( ) to trip over, and both files now compile-check on their own. Verified under /bin/bash 3.2: first apply replaces both symlinks, a package removed by hand still survives a relink, fragments merge for settings and for models, and repeat runs report unchanged. --- link/pi.sh | 243 +------------------------------------------------------------ 1 file changed, 4 insertions(+), 239 deletions(-) (limited to 'link/pi.sh') diff --git a/link/pi.sh b/link/pi.sh index 22ecd96..0fde248 100755 --- a/link/pi.sh +++ b/link/pi.sh @@ -40,187 +40,12 @@ then exit 0 fi -pi_status="$(DRY_RUN="$is_dry_run" python3 - \ +pi_status="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-settings.py" \ "$config_dir/pi/settings.json" \ "$xdg_config_dir/pi/settings.d" \ "$pi_dir/settings.json" \ "${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/pi-settings.json" \ - "$config_dir" <<'PY' -import glob, json, os, sys - -base_path, frag_dir, target, state_path, config_dir = sys.argv[1:6] -LIST_KEYS = ("packages", "extensions", "skills", "prompts", "themes") -PATH_KEYS = ("extensions", "skills", "prompts", "themes") - - -def load(path): - with open(path) as f: - return json.load(f) - - -def source(entry): - return entry if isinstance(entry, str) else entry.get("source") - - -def add_packages(into, entries): - for entry in entries: - key = source(entry) - existing = next((i for i, e in enumerate(into) if source(e) == key), None) - if existing is None: - into.append(entry) - elif not isinstance(entry, str): - into[existing] = entry - - -# What the repos want now. -desired = {} -for path in [base_path] + sorted(glob.glob(os.path.join(frag_dir, "*.json"))): - try: - layer = load(path) - except (OSError, ValueError) as e: - print("error:cannot read %s: %s" % (path, e)) - sys.exit(0) - here = os.path.dirname(os.path.realpath(path)) - for key, value in layer.items(): - if key in PATH_KEYS and path != base_path: - value = [v if os.path.isabs(os.path.expanduser(v)) else os.path.normpath(os.path.join(here, v)) - for v in value] - if key == "packages": - add_packages(desired.setdefault("packages", []), value) - elif key in LIST_KEYS: - merged = desired.setdefault(key, []) - merged.extend(v for v in value if v not in merged) - else: - desired[key] = value - -# What is there. A symlink is the pre-generation state: its target is a tracked -# file this repo now owns, so treat it as already matching. -def snapshot(): - """What is on disk now, to notice a write that lands before the lock.""" - try: - return (os.path.islink(target), open(target).read()) - except OSError: - return (os.path.islink(target), None) - - -on_disk = snapshot() -first_run = False -if os.path.islink(target): - first_run = True - try: - local = load(target) - except (OSError, ValueError): - local = {} - local = {k: v for k, v in local.items() if k not in desired and k not in LIST_KEYS and k != "packages"} - local.update(json.loads(json.dumps(desired))) - last = desired -else: - try: - local = load(target) - except FileNotFoundError: - local = {} - except ValueError as e: - print("error:%s is not valid JSON, leaving it: %s" % (target, e)) - sys.exit(0) - # No record of a previous run -- a new machine where pi made its own file - # first, or lost state: nothing is known to have come from the repos, so - # keys already there are kept and keys missing arrive. - try: - last = load(state_path) - except (OSError, ValueError): - last = {} - -MISSING = object() - - -def three_way(ours, before, theirs): - """ours: on disk; before: generated last time; theirs: generated now.""" - if ours == before: - return theirs - return ours - - -def merge_list(key, identity): - """Per entry, three-way, in generated order then local-only extras.""" - ours = {identity(e): e for e in local.get(key, [])} - before = {identity(e): e for e in last.get(key, [])} - theirs = {identity(e): e for e in desired.get(key, [])} - merged = [] - for ident in [identity(e) for e in desired.get(key, [])] + \ - [identity(e) for e in local.get(key, []) if identity(e) not in theirs]: - value = three_way(ours.get(ident, MISSING), before.get(ident, MISSING), theirs.get(ident, MISSING)) - if value is not MISSING: - merged.append(value) - return merged - - -result = {} -for key in list(local) + [k for k in desired if k not in local]: - if key in LIST_KEYS: - merged = merge_list(key, source if key == "packages" else (lambda e: e)) - if merged or key in local: - result[key] = merged - continue - value = three_way(local.get(key, MISSING), last.get(key, MISSING), desired.get(key, MISSING)) - if value is not MISSING: - result[key] = value - -changed = first_run or result != local -lock = target + ".lock" - - -def take_lock(): - """pi's own lock (proper-lockfile: a directory, stale after 10s), so neither - side can read, then write over the other's change.""" - import time - for _ in range(2): - try: - os.mkdir(lock) - return True - except FileExistsError: - try: - if time.time() - os.stat(lock).st_mtime > 10: - os.rmdir(lock) - continue - except FileNotFoundError: - continue - return False - return False - - -def save_state(): - os.makedirs(os.path.dirname(state_path), exist_ok=True) - with open(state_path + ".tmp", "w") as f: - json.dump(desired, f, indent=2) - os.replace(state_path + ".tmp", state_path) - - -if not changed: - print("unchanged:") - if not os.environ.get("DRY_RUN"): - save_state() -elif os.environ.get("DRY_RUN"): - print("would-write:" + ("replacing the link" if first_run else "merged changes")) -elif not take_lock(): - print("locked:") -elif snapshot() != on_disk: - # pi wrote between the read above and taking the lock: merge again next time - # rather than write over it. - os.rmdir(lock) - print("locked:") -else: - try: - tmp = target + ".dotfiles-tmp" - with open(tmp, "w") as f: - json.dump(result, f, indent=2) - f.write("\n") - os.replace(tmp, target) - save_state() - finally: - os.rmdir(lock) - print("wrote:" + ("replaced the link" if first_run else "merged changes")) -PY -)" + "$config_dir")" case "${pi_status%%:*}" in wrote) green "pi settings: ${pi_status#*:}" ;; @@ -242,70 +67,10 @@ esac # never writes it, keeping its runtime state in the sibling models-store.json -- # so what lands is whatever the repo and the fragments say. A hand-edit of # ~/.pi/agent/models.json does not survive a relink; put it in a fragment. -pi_models_out="$(DRY_RUN="$is_dry_run" python3 - \ +pi_models_out="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-models.py" \ "$config_dir/pi/models.json" \ "$xdg_config_dir/pi/models.d" \ - "$pi_dir/models.json" <<'PY' -import glob, json, os, sys - -base_path, frag_dir, target = sys.argv[1:4] - - -def load(path): - with open(path) as f: - return json.load(f) - - -# The repo's providers, then each fragment's. A fragment may add a provider or -# replace one of the same name; any other top-level key takes the last file's -# value. Which provider came from where is reported: a fragment quietly -# shadowing a provider the repo ships would otherwise be invisible. -desired = {} -notes = [] -for path in [base_path] + sorted(glob.glob(os.path.join(frag_dir, "*.json"))): - try: - layer = load(path) - except (OSError, ValueError) as e: - print("error:cannot read %s: %s" % (path, e)) - sys.exit(0) - for key, value in layer.items(): - if key == "providers": - providers = desired.setdefault("providers", {}) - if path != base_path: - for name in value: - notes.append("%s %s by %s" % ( - name, - "replaced" if name in providers else "added", - os.path.basename(path), - )) - providers.update(value) - else: - desired[key] = value - -# A symlink is the pre-generation state: replace it, even with no fragments, so -# the live file is never the tracked one. -was_link = os.path.islink(target) -try: - current = None if was_link else load(target) -except (OSError, ValueError): - current = None - -for note in notes: - print("note:" + note) - -if current == desired: - print("unchanged:") -elif os.environ.get("DRY_RUN"): - print("would-write:" + ("replacing the link" if was_link else "merged changes")) -else: - tmp = target + ".dotfiles-tmp" - with open(tmp, "w") as f: - json.dump(desired, f, indent=2) - f.write("\n") - os.replace(tmp, target) - print("wrote:" + ("replaced the link" if was_link else "merged changes")) -PY -)" + "$pi_dir/models.json")" while IFS= read -r line do -- cgit v1.2.3