-#!/usr/bin/env bash
-script_afp=$(realpath "${BASH_SOURCE[0]}")
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
-# This is a bespoke script for copying files into ../release.
-# Files in ../release can be used by the tester role.
-# On a release branch, files in the ../release constitute the public release.
+import os, sys, shutil, stat, pwd, grp, glob, tempfile
-# conventional preliminaries
-#
- env_must_be="developer/tool/env"
- if [ "$ENV" != "$env_must_be" ]; then
- echo "$(script_fp):: error: must be run in the $env_must_be environment"
- exit 1
- fi
+HELP = """usage: release {write|clean|ls|help|dry write} [DIR]
+ write [DIR] Writes released files into $REPO_HOME/release. If [DIR] is specified, only writes files found in scratchpad/DIR.
+ clean [DIR] Remove the contents of the release directories. If [DIR] is specified, clean only the contents of that release directory.
+ ls List release/ as an indented tree: PERMS OWNER NAME (root-level dotfiles printed first).
+ help Show this message.
+ dry write [DIR]
+ Preview what write would do without modifying the filesystem.
+"""
- cd "$REPO_HOME"/developer || exit 1
+ENV_MUST_BE = "developer/tool/env"
+DEFAULT_DIR_MODE = 0o750
+PERM_BY_DIR = {
+ "kmod": 0o440,
+ "machine": 0o550,
+ "python3": 0o550,
+ "shell": 0o550,
+}
-#set -e
-#set -x
+def exit_with_status(msg, code=1):
+ print(f"release: {msg}", file=sys.stderr)
+ sys.exit(code)
-release_dir="$REPO_HOME/release"
+def assert_env():
+ env = os.environ.get("ENV", "")
+ if env != ENV_MUST_BE:
+ hint = (
+ "ENV is not 'developer/tool/env'.\n"
+ "Enter the project with: source ./env_developer\n"
+ "That script exports: ROLE=developer; ENV=$ROLE/tool/env"
+ )
+ exit_with_status(f"bad environment: ENV='{env}'. {hint}")
-# examples
+def repo_home():
+ rh = os.environ.get("REPO_HOME")
+ if not rh:
+ exit_with_status("REPO_HOME not set (did you 'source ./env_developer'?)")
+ return rh
- # yes machine directory, but no release/kmod directory, on the skeleton
- mkdir -p ${release_dir}/kmod && install -m 0600 scratchpad/*.ko "${release_dir}/kmod/"
- install -m 0700 scratchpad/hello "${release_dir}/machine/"
+def dpath(*parts):
+ return os.path.join(repo_home(), "developer", *parts)
+def rpath(*parts):
+ return os.path.join(repo_home(), "release", *parts)
-echo "$(script_fn) done."
+def dev_root():
+ return dpath()
+def rel_root():
+ return rpath()
+
+def _display_src(p_abs: str) -> str:
+ # Developer paths shown relative to $REPO_HOME/developer
+ try:
+ if os.path.commonpath([dev_root()]) == os.path.commonpath([dev_root(), p_abs]):
+ return os.path.relpath(p_abs, dev_root())
+ except Exception:
+ pass
+ return p_abs
+
+def _display_dst(p_abs: str) -> str:
+ # Release paths shown as literal '$REPO_HOME/release/<rel>'
+ try:
+ rel = os.path.relpath(p_abs, rel_root())
+ rel = "" if rel == "." else rel
+ return "$REPO_HOME/release" + ("/" + rel if rel else "")
+ except Exception:
+ return p_abs
+
+def ensure_mode(path, mode):
+ try: os.chmod(path, mode)
+ except Exception: pass
+
+def ensure_dir(path, mode=DEFAULT_DIR_MODE, dry=False):
+ if dry:
+ if not os.path.isdir(path):
+ shown = _display_dst(path) if path.startswith(rel_root()) else (
+ os.path.relpath(path, dev_root()) if path.startswith(dev_root()) else path
+ )
+ print(f"(dry) mkdir -m {oct(mode)[2:]} '{shown}'")
+ return
+ os.makedirs(path, exist_ok=True)
+ ensure_mode(path, mode)
+
+def filemode(m):
+ try: return stat.filemode(m)
+ except Exception: return oct(m & 0o777)
+
+def owner_group(st):
+ try: return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}"
+ except Exception: return f"{st.st_uid}:{st.st_gid}"
+
+# ---------- LS with two-pass column width for owner:group ----------
+def list_tree(root):
+ if not os.path.isdir(root):
+ return
+
+ # gather entries in display order, record owner:group widths
+ entries = [] # list of (is_dir, depth, perms, ownergrp, name)
+ def gather(path: str, depth: int, is_root: bool):
+ try:
+ it = list(os.scandir(path))
+ except FileNotFoundError:
+ return
+ dirs = [e for e in it if e.is_dir(follow_symlinks=False)]
+ files = [e for e in it if not e.is_dir(follow_symlinks=False)]
+ dirs.sort(key=lambda e: e.name)
+ files.sort(key=lambda e: e.name)
+
+ if is_root:
+ # root-level: dotfiles first
+ for f in (e for e in files if e.name.startswith(".")):
+ st = os.lstat(f.path)
+ entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name))
+ for d in dirs:
+ st = os.lstat(d.path)
+ entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/"))
+ gather(d.path, depth + 1, False)
+ for f in (e for e in files if not e.name.startswith(".")):
+ st = os.lstat(f.path)
+ entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name))
+ else:
+ # subdirs: dirs then files (dotfiles naturally sort first)
+ for d in dirs:
+ st = os.lstat(d.path)
+ entries.append((True, depth, filemode(st.st_mode), owner_group(st), d.name + "/"))
+ gather(d.path, depth + 1, False)
+ for f in files:
+ st = os.lstat(f.path)
+ entries.append((False, depth, filemode(st.st_mode), owner_group(st), f.name))
+
+ gather(root, depth=1, is_root=True)
+
+ # compute max width for owner:group column
+ ogw = 0
+ for (_isdir, _depth, _perms, ownergrp, _name) in entries:
+ if len(ownergrp) > ogw:
+ ogw = len(ownergrp)
+
+ # print
+ print("release/")
+ for (isdir, depth, perms, ownergrp, name) in entries:
+ indent = " " * depth
+ # perms first, owner:group padded next, then name with tree indent
+ print(f"{perms} {ownergrp:<{ogw}} {indent}{name}")
+
+# ---------- end LS ----------
+
+def iter_src_files(topdir, src_root):
+ base = os.path.join(src_root, topdir) if topdir else src_root
+ if not os.path.isdir(base):
+ return
+ yield
+ if topdir == "kmod":
+ for p in sorted(glob.glob(os.path.join(base, "*.ko"))):
+ yield (p, os.path.basename(p))
+ else:
+ for root, dirs, files in os.walk(base):
+ dirs.sort(); files.sort()
+ for fn in files:
+ src = os.path.join(root, fn)
+ rel = os.path.relpath(src, base)
+ yield (src, rel)
+
+def target_mode(topdir):
+ return PERM_BY_DIR.get(topdir, 0o440)
+
+def copy_one(src_abs, dst_abs, mode, dry=False):
+ src_show = _display_src(src_abs)
+ dst_show = _display_dst(dst_abs)
+ parent = os.path.dirname(dst_abs)
+ os.makedirs(parent, exist_ok=True)
+
+ if dry:
+ if os.path.exists(dst_abs):
+ print(f"(dry) unlink '{dst_show}'")
+ print(f"(dry) install -m {oct(mode)[2:]} -D '{src_show}' '{dst_show}'")
+ return
+
+ # Replace even if dst exists and is read-only: write temp then atomic replace.
+ fd, tmp_path = tempfile.mkstemp(prefix=".tmp.", dir=parent)
+ try:
+ with os.fdopen(fd, "wb") as tmpf, open(src_abs, "rb") as sf:
+ shutil.copyfileobj(sf, tmpf)
+ tmpf.flush()
+ os.chmod(tmp_path, mode)
+ os.replace(tmp_path, dst_abs)
+ finally:
+ try:
+ if os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+ except Exception:
+ pass
+
+ print(f"+ install -m {oct(mode)[2:]} '{src_show}' '{dst_show}'")
+
+def write_one_dir(topdir, dry):
+ rel_root_dir = rpath()
+ src_root = dpath("scratchpad")
+ src_dir = os.path.join(src_root, topdir)
+ dst_dir = os.path.join(rel_root_dir, topdir)
+
+ if not os.path.isdir(src_dir):
+ exit_with_status(
+ f"cannot write: expected '{_display_src(src_dir)}' to exist. "
+ f"Create scratchpad/{topdir} (Makefiles may need to populate it)."
+ )
+
+ ensure_dir(dst_dir, DEFAULT_DIR_MODE, dry=dry)
+
+ wrote = False
+ mode = target_mode(topdir)
+ for src_abs, rel in iter_src_files(topdir, src_root):
+ dst_abs = os.path.join(dst_dir, rel)
+ copy_one(src_abs, dst_abs, mode, dry=dry)
+ wrote = True
+ if not wrote:
+ msg = "no matching artifacts found"
+ if topdir == "kmod": msg += " (looking for *.ko)"
+ print(f"(info) {msg} in {_display_src(src_dir)}")
+
+def cmd_write(dir_arg, dry=False):
+ assert_env()
+ ensure_dir(rpath(), DEFAULT_DIR_MODE, dry=dry)
+
+ src_root = dpath("scratchpad")
+ if not os.path.isdir(src_root):
+ exit_with_status(f"cannot find developer scratchpad at '{_display_src(src_root)}'")
+
+ if dir_arg:
+ write_one_dir(dir_arg, dry=dry)
+ else:
+ subs = sorted([e.name for e in os.scandir(src_root) if e.is_dir(follow_symlinks=False)])
+ if not subs:
+ print(f"(info) nothing to release; no subdirectories found under {_display_src(src_root)}")
+ return
+ for td in subs:
+ write_one_dir(td, dry=dry)
+
+def _clean_contents(dir_path):
+ if not os.path.isdir(dir_path): return
+ for name in os.listdir(dir_path):
+ p = os.path.join(dir_path, name)
+ if os.path.isdir(p) and not os.path.islink(p):
+ shutil.rmtree(p, ignore_errors=True)
+ else:
+ try: os.unlink(p)
+ except FileNotFoundError: pass
+
+def cmd_clean(dir_arg):
+ assert_env()
+ rel_root_dir = rpath()
+ if not os.path.isdir(rel_root_dir):
+ return
+ if dir_arg:
+ _clean_contents(os.path.join(rel_root_dir, dir_arg))
+ else:
+ for e in os.scandir(rel_root_dir):
+ if e.is_dir(follow_symlinks=False):
+ _clean_contents(e.path)
+
+def CLI():
+ if len(sys.argv) < 2:
+ print(HELP); return
+ cmd, *args = sys.argv[1:]
+ if cmd == "write":
+ cmd_write(args[0] if args else None, dry=False)
+ elif cmd == "clean":
+ cmd_clean(args[0] if args else None)
+ elif cmd == "ls":
+ list_tree(rpath())
+ elif cmd == "help":
+ print(HELP)
+ elif cmd == "dry":
+ if args and args[0] == "write":
+ cmd_write(args[1] if len(args) >= 2 else None, dry=True)
+ else:
+ print(HELP)
+ else:
+ print(HELP)
+
+if __name__ == "__main__":
+ CLI()
--- /dev/null
+#!/usr/bin/env -S python3 -B
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+
+import os, sys, shutil, stat, pwd, grp, subprocess
+
+HELP = """usage: scratchpad {ls|clear|help|make|write|size|find|lock|unlock} [ARGS]
+ ls List scratchpad in an indented tree with perms and owner (quiet if missing).
+ clear Remove all contents of scratchpad/ except top-level .gitignore.
+ clear NAME Remove scratchpad/NAME only.
+ make [NAME] Ensure scratchpad/ exists with .gitignore; with NAME, mkdir scratchpad/NAME.
+ write SRC [DST] Copy file/dir SRC into scratchpad (to DST if given; parents created).
+ size Print 'empty' if only .gitignore; else total bytes and item count.
+ find [OPTS...] Run system 'find' rooted at scratchpad/ with OPTS (omit literal 'scratchpad').
+ lock PATH... Attempt 'chattr +i' on given paths under scratchpad/ (no state kept).
+ unlock PATH... Attempt 'chattr -i' on given paths under scratchpad/.
+
+Examples:
+ scratchpad make
+ scratchpad write ~/Downloads/test.tar.gz
+ scratchpad find -type f -mtime +30 -print # files older than 30 days
+ scratchpad lock some/dir important.txt
+ scratchpad unlock some/dir important.txt
+"""
+
+CWD = os.getcwd()
+SP = os.path.join(CWD, "scratchpad")
+GITIGNORE = os.path.join(SP, ".gitignore")
+
+def have_sp() -> bool:
+ return os.path.isdir(SP)
+
+def ensure_sp():
+ os.makedirs(SP, exist_ok=True)
+ ensure_gitignore()
+
+def ensure_gitignore():
+ os.makedirs(SP, exist_ok=True)
+ if not os.path.isfile(GITIGNORE):
+ with open(GITIGNORE, "w", encoding="utf-8") as f:
+ f.write("*\n!.gitignore\n")
+
+def filemode(mode: int) -> str:
+ try:
+ return stat.filemode(mode)
+ except Exception:
+ return oct(mode & 0o777)
+
+def owner_group(st) -> str:
+ try:
+ return f"{pwd.getpwuid(st.st_uid).pw_name}:{grp.getgrgid(st.st_gid).gr_name}"
+ except Exception:
+ return f"{st.st_uid}:{st.st_gid}"
+
+def rel_depth(base: str, root: str) -> int:
+ rel = os.path.relpath(base, root)
+ return 0 if rel == "." else rel.count(os.sep) + 1
+
+def ls_tree(root: str) -> None:
+ if not have_sp():
+ return
+ print("scratchpad/")
+
+ def walk(path: str, indent: str, is_root: bool) -> None:
+ try:
+ it = list(os.scandir(path))
+ except FileNotFoundError:
+ return
+
+ dirs = [e for e in it if e.is_dir(follow_symlinks=False)]
+ files = [e for e in it if not e.is_dir(follow_symlinks=False)]
+ dirs.sort(key=lambda e: e.name)
+ files.sort(key=lambda e: e.name)
+
+ if is_root:
+ # 1) root-level hidden files first
+ for f in (e for e in files if e.name.startswith(".")):
+ st = os.lstat(f.path)
+ print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{f.name}")
+ # 2) then directories (and recurse so children sit under the parent)
+ for d in dirs:
+ st = os.lstat(d.path)
+ print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{d.name}/")
+ walk(d.path, indent + ' ', False)
+ # 3) then non-hidden files
+ for f in (e for e in files if not e.name.startswith(".")):
+ st = os.lstat(f.path)
+ print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{f.name}")
+ else:
+ # subdirs: keep previous order (dirs first, then files; dotfiles naturally sort first)
+ for d in dirs:
+ st = os.lstat(d.path)
+ print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{d.name}/")
+ walk(d.path, indent + ' ', False)
+ for f in files:
+ st = os.lstat(f.path)
+ print(f"{filemode(st.st_mode)} {owner_group(st)} {indent}{f.name}")
+
+ walk(root, " ", True)
+
+
+def clear_all() -> None:
+ if not have_sp():
+ return
+ for name in os.listdir(SP):
+ p = os.path.join(SP, name)
+ if name == ".gitignore" and os.path.isfile(p):
+ continue # preserve only top-level .gitignore
+ if os.path.isdir(p) and not os.path.islink(p):
+ shutil.rmtree(p, ignore_errors=True)
+ else:
+ try: os.unlink(p)
+ except FileNotFoundError: pass
+
+def clear_subdir(sub: str) -> None:
+ if not have_sp():
+ return
+ target = os.path.normpath(os.path.join(SP, sub))
+ try:
+ if os.path.commonpath([SP]) != os.path.commonpath([SP, target]):
+ return
+ except Exception:
+ return
+ if os.path.isdir(target) and not os.path.islink(target):
+ shutil.rmtree(target, ignore_errors=True)
+
+def cmd_make(args):
+ ensure_sp()
+ if args:
+ os.makedirs(os.path.join(SP, args[0]), exist_ok=True)
+
+def cmd_write(args):
+ if len(args) < 1:
+ print(HELP); return
+ if not have_sp():
+ ensure_sp()
+ src = args[0]
+ dst = args[1] if len(args) >= 2 else (os.path.basename(src.rstrip(os.sep)) or "untitled")
+ dst_path = os.path.normpath(os.path.join(SP, dst))
+ try:
+ if os.path.commonpath([SP]) != os.path.commonpath([SP, dst_path]):
+ print("refusing to write outside scratchpad", file=sys.stderr); return
+ except Exception:
+ print("invalid destination", file=sys.stderr); return
+ os.makedirs(os.path.dirname(dst_path), exist_ok=True)
+ if os.path.isdir(src):
+ if os.path.exists(dst_path):
+ shutil.rmtree(dst_path, ignore_errors=True)
+ shutil.copytree(src, dst_path, dirs_exist_ok=False)
+ else:
+ shutil.copy2(src, dst_path)
+
+def cmd_size():
+ if not have_sp():
+ return
+ names = os.listdir(SP)
+ if [n for n in names if n != ".gitignore"] == []:
+ print("empty"); return
+ total = 0; count = 0
+ for base, dirs, files in os.walk(SP):
+ for fn in files:
+ if fn == ".gitignore":
+ continue
+ p = os.path.join(base, fn)
+ try:
+ total += os.path.getsize(p); count += 1
+ except OSError:
+ pass
+ print(f"bytes={total} items={count}")
+
+def cmd_find(args):
+ if not have_sp():
+ return
+ try:
+ subprocess.run(["find", SP] + args, check=False)
+ except FileNotFoundError:
+ print("find not available", file=sys.stderr)
+
+def cmd_chattr(flag: str, paths):
+ if not have_sp() or not paths:
+ return
+ try:
+ subprocess.run(["chattr", "-V"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
+ except FileNotFoundError:
+ print("chattr not available; lock/unlock skipped", file=sys.stderr); return
+ for rel in paths:
+ target = os.path.normpath(os.path.join(SP, rel))
+ try:
+ if os.path.commonpath([SP]) != os.path.commonpath([SP, target]):
+ continue
+ except Exception:
+ continue
+ try:
+ subprocess.run(["chattr", flag, target], check=False)
+ except Exception:
+ pass
+
+def CLI():
+ if len(sys.argv) < 2:
+ print(HELP); return
+ cmd, *args = sys.argv[1:]
+ if cmd == "ls":
+ if have_sp(): ls_tree(SP)
+ else: return
+ elif cmd == "clear":
+ if len(args) >= 1: clear_subdir(args[0])
+ else: clear_all()
+ elif cmd == "help":
+ print(HELP)
+ elif cmd == "make":
+ cmd_make(args)
+ elif cmd == "write":
+ cmd_write(args)
+ elif cmd == "size":
+ cmd_size()
+ elif cmd == "find":
+ cmd_find(args)
+ elif cmd == "lock":
+ cmd_chattr("+i", args)
+ elif cmd == "unlock":
+ cmd_chattr("-i", args)
+ else:
+ print(HELP)
+
+if __name__ == "__main__":
+ CLI()