From 9bb8a8451ebf215ba86c3c3b63a419247c4a6e58 Mon Sep 17 00:00:00 2001
From: Thomas Walker Lynch
Date: Tue, 10 Nov 2026 13:37:29 +0000
Subject: [PATCH] .
---
.gitignore | 2 +-
0pus_AccountTree | 0
administrator/tool/skeleton_update | 621 ++++++++++++++
developer/authored/.gitkeep | 0
.../deprecated/tester}/scratchpad/.gitignore | 0
developer/design_AccountTree.html | 761 ++++++++++++++++++
document/design_AccountTree.html | 28 +-
setup | 52 +-
shared/project-dependency/Python.org | 95 +--
shared/tool/env_to_emacs | 74 +-
shared/tool/makefile/Harmony.mk | 4 +-
shared/tool/setup | 2 +
shared/tool/version | 2 +
13 files changed, 1527 insertions(+), 114 deletions(-)
create mode 100644 0pus_AccountTree
create mode 100755 administrator/tool/skeleton_update
create mode 100644 developer/authored/.gitkeep
rename {tester => developer/authored/deprecated/tester}/scratchpad/.gitignore (100%)
create mode 100644 developer/design_AccountTree.html
diff --git a/.gitignore b/.gitignore
index 9538920..905d115 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,6 @@ __pycache__/
*.swp
.DS_Store
-# SubU
+# AccountTree
__pycache__
*.sqlite3
diff --git a/0pus_AccountTree b/0pus_AccountTree
new file mode 100644
index 0000000..e69de29
diff --git a/administrator/tool/skeleton_update b/administrator/tool/skeleton_update
new file mode 100755
index 0000000..716e342
--- /dev/null
+++ b/administrator/tool/skeleton_update
@@ -0,0 +1,621 @@
+#!/usr/bin/env python3
+# -*- mode: python; coding: utf-8; python-indent-offset: 2; indent-tabs-mode: nil -*-
+"""
+skeleton_update
+
+Brings a project onto the current Harmony skeleton. Skeleton files are
+copied in from a Harmony checkout, and .gitignore and shared/tool/version are
+merged with the project's own lines. The project's files are then sorted:
+
+ a file in a place Harmony has, such as developer/authored/ or a directory
+ of the skeleton, stays where it is;
+ a file the plan moves is renamed to its new place;
+ a file under a plan's duplicate tree is deleted when the project holds a
+ file with the same content elsewhere, and is otherwise deprecated;
+ every other file is deprecated: moved, with its path kept, under
+ developer/authored/deprecated/, so nothing is lost and the original place
+ of each file can be read from its new path.
+
+The plan is a Python file giving what is particular to one project. A
+project already laid out after Harmony needs no plan, and the tool then
+updates its skeleton files only. The plan's names are:
+
+ MOVE {old path: new path}; a path ending in / moves a directory tree
+ DEPRECATE paths that are deprecated even though Harmony has their place
+ DUPLICATE trees whose files are copies of files kept elsewhere
+ KEEP paths kept as they are, including skeleton files the project
+ has its own version of
+ EXECUTABLE paths, after any move, given execute permission where read
+ permission is given
+
+The files sorted are those git tracks or would track: the work tree less what
+the project's .gitignore ignores. Ignored files, such as a local Python
+environment, are left where they are and listed at the end of the log.
+Compiled Python caches are deleted. Directories left empty are removed.
+
+Commit the project first; run refuses a work tree with uncommitted changes.
+Afterward, 'git add -A' records the moves, and git recognizes each moved file
+as a rename by its content, so 'git log --follow' follows its history. Run
+again after committing, and the log reports nothing left to do.
+
+Usage: skeleton_update run []
+ skeleton_update preview run []
+ help | -h | --help print this message (also printed when given no arguments)
+ run bring onto the skeleton of
+ preview run print the log of what run would do, change nothing
+"""
+
+import sys ,os ,shutil ,filecmp ,subprocess ,runpy ,hashlib
+from pathlib import Path
+
+
+# --------------------------------------------------------------------------------
+# what the tool knows about Harmony
+
+DEPRECATED_DIR = "developer/authored/deprecated"
+
+# Skeleton files a project does not take: it has its own.
+SKELETON_SKIP_FILE = {"README.md" ,"LICENSE" ,"0pus_Harmony"}
+SKELETON_SKIP_TREE = ("developer/authored/ExampleGreet.make/",)
+
+# Skeleton files merged with the project's version rather than replaced.
+MERGED_FILE = (".gitignore" ,"shared/tool/version")
+
+# Files at the top of a project that belong to the project.
+PROJECT_ROOT_FILE = {"LICENSE" ,"README.md" ,".gitattributes" ,".gitmodules"}
+
+# Trees in which every file belongs to the project.
+PROJECT_TREE = (
+ "administrator/authored/",
+ "administrator/document/",
+ "developer/authored/",
+ "developer/document/",
+ "developer/experiment/",
+ "document/",
+ "shared/authored/",
+ "tester/authored/",
+ "tester/document/",
+)
+
+# Placeholder files of the old skeleton; Harmony uses .gitkeep.
+PLACEHOLDER_NAME = {".githolder"}
+
+CACHE_DIR_NAME = {"__pycache__"}
+
+PLAN_NAME = ("MOVE" ,"DEPRECATE" ,"DUPLICATE" ,"KEEP" ,"EXECUTABLE")
+
+
+# --------------------------------------------------------------------------------
+# the log
+
+TAG_WIDTH = 18
+
+# the tag a preview prints in place of each past-tense tag
+PREVIEW_TAG = {
+ "added": "add" ,"deleted": "delete" ,"deprecated": "deprecate" ,"kept": "keep",
+ "left alone": "leave alone" ,"made executable": "make executable" ,"merged": "merge",
+ "moved": "move" ,"removed": "remove",
+ "replaced": "replace",
+}
+
+class Log:
+ def __init__(self ,preview):
+ self.preview = preview
+ self.count = {}
+ self.error_count = 0
+
+ def line(self ,tag ,rel ,reason=None):
+ if self.preview: tag = "would " + PREVIEW_TAG.get(tag ,tag)
+ text = f" {tag:<{TAG_WIDTH}}{rel}"
+ if reason: text += f" ({reason})"
+ print(text)
+
+ def tally(self ,key ,n=1):
+ self.count[key] = self.count.get(key ,0) + n
+
+ def error(self ,rel ,message):
+ self.error_count += 1
+ print(f" {'error':<{TAG_WIDTH}}{rel} ({message})")
+
+ def summary(self):
+ print("summary:")
+ order = [
+ ("moved" ,"files moved to their place by the plan"),
+ ("deprecated" ,"files moved under " + DEPRECATED_DIR),
+ ("deleted duplicate" ,"duplicate files deleted"),
+ ("deleted placeholder" ,".githolder files deleted"),
+ ("deleted cache" ,"__pycache__ directories deleted"),
+ ("skeleton added" ,"skeleton files added"),
+ ("skeleton replaced" ,"skeleton files replaced"),
+ ("skeleton current" ,"skeleton files already current"),
+ ("skeleton kept" ,"skeleton files kept in the project's own version"),
+ ("merged" ,"files merged with the skeleton"),
+ ("merged current" ,"merged files already current"),
+ ("kept" ,"project files kept in place"),
+ ("made executable" ,"files made executable"),
+ ("left alone" ,"ignored files left in place, listed above"),
+ ("directory removed" ,"empty directories removed"),
+ ]
+ for key ,label in order:
+ print(f" {self.count.get(key ,0):>4} {label}")
+ if self.error_count:
+ print(f" {self.error_count:>4} errors")
+ if self.preview:
+ print("preview only: nothing was changed.")
+
+
+# --------------------------------------------------------------------------------
+# reading git
+
+def git(directory ,*argument):
+ return subprocess.run(
+ ["git" ,"-C" ,str(directory) ,*argument],
+ capture_output=True ,text=True ,check=True
+ ).stdout
+
+def git_dirty(directory):
+ return git(directory ,"status" ,"--porcelain").strip()
+
+def project_file_list(project):
+ """
+ The files git tracks or would track, that exist: tracked files, and
+ untracked files the project's .gitignore does not ignore.
+ """
+ listing = git(project ,"ls-files" ,"-z" ,"--cached" ,"--others" ,"--exclude-standard").split("\0")
+ result = set()
+ for rel in listing:
+ if rel and ((project / rel).exists() or (project / rel).is_symlink()):
+ result.add(rel)
+ return sorted(result)
+
+def ignored_file_list(project):
+ listing = git(project ,"ls-files" ,"-z" ,"--others" ,"--ignored" ,"--exclude-standard").split("\0")
+ return sorted(rel for rel in listing if rel)
+
+def skeleton_file_list(harmony):
+ """
+ The skeleton is the set of files in Harmony's last commit, as new-project
+ takes it.
+ """
+ result = []
+ for rel in git(harmony ,"ls-tree" ,"-r" ,"--name-only" ,"-z" ,"HEAD").split("\0"):
+ if not rel or rel in SKELETON_SKIP_FILE or in_tree(rel ,SKELETON_SKIP_TREE):
+ continue
+ result.append(rel)
+ return sorted(result)
+
+
+# --------------------------------------------------------------------------------
+# paths
+
+def in_tree(rel ,tree_tuple):
+ return any(rel.startswith(t) for t in tree_tuple)
+
+def matches(rel ,path_tuple):
+ """A path names a file exactly, or, ending in /, the tree under it."""
+ return any(rel == p or (p.endswith("/") and rel.startswith(p)) for p in path_tuple)
+
+def moved_path(rel ,move):
+ for old ,new in move.items():
+ if rel == old:
+ return new
+ if old.endswith("/") and rel.startswith(old):
+ return new + rel[len(old):]
+ return None
+
+def directory_set(rel_list):
+ result = set()
+ for rel in rel_list:
+ parent = Path(rel).parent
+ while parent != Path("."):
+ result.add(parent.as_posix())
+ parent = parent.parent
+ return result
+
+def content_hash(path):
+ if path.is_symlink():
+ return "link:" + os.readlink(path)
+ h = hashlib.sha256()
+ with open(path ,"rb") as f:
+ for block in iter(lambda: f.read(1 << 16) ,b""):
+ h.update(block)
+ return h.hexdigest()
+
+
+# --------------------------------------------------------------------------------
+# file operations; each tests preview immediately before acting
+
+def same_leaf(a ,b):
+ if a.is_symlink() or b.is_symlink():
+ return a.is_symlink() and b.is_symlink() and os.readlink(a) == os.readlink(b)
+ return a.is_file() and b.is_file() and filecmp.cmp(a ,b ,shallow=False)
+
+def copy_leaf(src ,dst ,preview):
+ if preview: return
+ dst.parent.mkdir(parents=True ,exist_ok=True)
+ if dst.is_symlink() or dst.exists():
+ dst.unlink()
+ if src.is_symlink():
+ os.symlink(os.readlink(src) ,dst)
+ else:
+ shutil.copy2(src ,dst)
+
+def move_leaf(src ,dst ,preview):
+ if preview: return
+ dst.parent.mkdir(parents=True ,exist_ok=True)
+ os.rename(src ,dst)
+
+def delete_leaf(path ,preview):
+ if preview: return
+ path.unlink()
+
+def delete_tree(path ,preview):
+ if preview: return
+ shutil.rmtree(path)
+
+def write_text(path ,text ,preview):
+ if preview: return
+ path.parent.mkdir(parents=True ,exist_ok=True)
+ path.write_text(text ,encoding="utf-8")
+
+
+# --------------------------------------------------------------------------------
+# merging
+
+def merged_gitignore(project_text ,harmony_text ,project_name):
+ """
+ Harmony's lines, then a section holding the project's patterns that
+ Harmony lacks. The project's comments and blank lines are not carried.
+ """
+ section = "# " + project_name
+ harmony_line = set(harmony_text.splitlines())
+ extra = []
+ for line in project_text.splitlines():
+ pattern = line.strip()
+ if not pattern or pattern.startswith("#"):
+ continue
+ if pattern not in harmony_line and pattern not in extra:
+ extra.append(pattern)
+ text = harmony_text.rstrip("\n") + "\n"
+ if extra:
+ text += "\n" + section + "\n" + "\n".join(extra) + "\n"
+ return text
+
+def merged_version(project_text ,harmony_text ,project_name):
+ """Harmony's file, then the project's lines that do not name Harmony."""
+ project_line = [l for l in project_text.splitlines() if l.strip() and "Harmony" not in l and l not in harmony_text.splitlines()]
+ text = harmony_text if harmony_text.endswith("\n") else harmony_text + "\n"
+ return text + "".join(l + "\n" for l in project_line)
+
+MERGE_FUNCTION = {
+ ".gitignore": merged_gitignore,
+ "shared/tool/version": merged_version,
+}
+
+
+# --------------------------------------------------------------------------------
+# the work
+
+def step_cache(project ,log ,preview):
+ for dirpath ,dirnames ,filenames in os.walk(project):
+ dirnames.sort()
+ if Path(dirpath) == project and ".git" in dirnames:
+ dirnames.remove(".git")
+ for name in list(dirnames):
+ if name in CACHE_DIR_NAME:
+ rel = Path(dirpath ,name).relative_to(project).as_posix()
+ delete_tree(project / rel ,preview)
+ log.line("deleted" ,rel + "/" ,"compiled Python cache")
+ log.tally("deleted cache")
+ dirnames.remove(name)
+
+def decide(rel ,plan ,skeleton_set ,skeleton_dir ,ignored_list):
+ action = decide_place(rel ,plan ,skeleton_set ,skeleton_dir)
+ if Path(rel).name == ".gitignore" and action[0] in ("move" ,"duplicate" ,"deprecate"):
+ prefix = Path(rel).parent.as_posix() + "/"
+ n = sum(1 for i in ignored_list if i.startswith(prefix))
+ if n:
+ return ("keep" ,rel ,f"it hides {n} ignored file{'s' if n != 1 else ''}, left in place")
+ return action
+
+def decide_place(rel ,plan ,skeleton_set ,skeleton_dir):
+ """
+ What becomes of one project file: (action ,new path ,reason). The action is
+ one of placeholder, keep, keep skeleton, move, duplicate, deprecate, or
+ skeleton, which the skeleton steps handle.
+ """
+ name = Path(rel).name
+ if name in PLACEHOLDER_NAME:
+ return ("placeholder" ,None ,None)
+ if matches(rel ,plan["KEEP"]):
+ return ("keep skeleton" if rel in skeleton_set else "keep" ,rel ,None)
+ new = moved_path(rel ,plan["MOVE"])
+ if new is not None:
+ return ("move" ,new ,None)
+ if matches(rel ,plan["DUPLICATE"]):
+ return ("duplicate" ,f"{DEPRECATED_DIR}/{rel}" ,None)
+ if matches(rel ,plan["DEPRECATE"]):
+ return ("deprecate" ,f"{DEPRECATED_DIR}/{rel}" ,"by the plan")
+ if rel in skeleton_set or rel in MERGED_FILE:
+ return ("skeleton" ,rel ,None)
+ parent = Path(rel).parent.as_posix()
+ if (parent == "." and (name in PROJECT_ROOT_FILE or name.startswith("0pus_"))) \
+ or in_tree(rel ,PROJECT_TREE) or parent in skeleton_dir:
+ return ("keep" ,rel ,None)
+ return ("deprecate" ,f"{DEPRECATED_DIR}/{rel}" ,None)
+
+def step_sort(project ,plan ,skeleton_set ,file_list ,ignored_list ,log ,preview):
+ """
+ Place every project file that is not a skeleton file. Every decision is
+ made before anything moves, so a duplicate can name where its twin ends up.
+ """
+ skeleton_dir = directory_set(skeleton_set)
+ decision = {rel: decide(rel ,plan ,skeleton_set ,skeleton_dir ,ignored_list) for rel in file_list}
+
+ # where the content of each file that stays in the project ends up
+ kept_at = {}
+ for rel ,(action ,new ,reason) in decision.items():
+ if action in ("keep" ,"keep skeleton" ,"move" ,"deprecate" ,"skeleton"):
+ kept_at.setdefault(content_hash(project / rel) ,new)
+
+ for rel in file_list:
+ action ,new ,reason = decision[rel]
+ if action == "placeholder":
+ delete_leaf(project / rel ,preview)
+ log.line("deleted" ,rel ,"placeholder; Harmony uses .gitkeep")
+ log.tally("deleted placeholder")
+ elif action == "keep skeleton":
+ log.line("kept" ,rel ,"the project's own version, by the plan")
+ log.tally("skeleton kept")
+ elif action == "keep":
+ if reason: log.line("kept" ,rel ,reason)
+ log.tally("kept")
+ elif action == "move":
+ place(project ,rel ,new ,"moved" ,f"to {new}" ,log ,preview)
+ elif action == "duplicate":
+ twin = kept_at.get(content_hash(project / rel))
+ if twin:
+ delete_leaf(project / rel ,preview)
+ log.line("deleted" ,rel ,f"the same as {twin}")
+ log.tally("deleted duplicate")
+ else:
+ place(project ,rel ,new ,"deprecated" ,"no copy of it is kept" ,log ,preview)
+ elif action == "deprecate":
+ place(project ,rel ,new ,"deprecated" ,reason ,log ,preview)
+
+def place(project ,rel ,new ,tag ,reason ,log ,preview):
+ dst = project / new
+ if dst.exists() or dst.is_symlink():
+ log.error(rel ,f"{new} already exists; nothing was moved")
+ return
+ move_leaf(project / rel ,dst ,preview)
+ log.line(tag ,rel ,reason)
+ log.tally(tag)
+
+def step_skeleton(project ,harmony ,skeleton_list ,plan ,log ,preview):
+ for rel in skeleton_list:
+ if rel in MERGED_FILE or matches(rel ,plan["KEEP"]):
+ continue
+ src = harmony / rel
+ dst = project / rel
+ if dst.exists() or dst.is_symlink():
+ if same_leaf(src ,dst):
+ log.tally("skeleton current")
+ continue
+ copy_leaf(src ,dst ,preview)
+ log.line("replaced" ,rel ,"from the skeleton")
+ log.tally("skeleton replaced")
+ else:
+ copy_leaf(src ,dst ,preview)
+ log.line("added" ,rel ,"from the skeleton")
+ log.tally("skeleton added")
+
+def step_merge(project ,harmony ,project_name ,plan ,log ,preview):
+ for rel in MERGED_FILE:
+ if matches(rel ,plan["KEEP"]):
+ continue
+ project_path = project / rel
+ harmony_text = (harmony / rel).read_text(encoding="utf-8")
+ project_text = project_path.read_text(encoding="utf-8") if project_path.is_file() else ""
+ text = MERGE_FUNCTION[rel](project_text ,harmony_text ,project_name)
+ if text == project_text:
+ log.tally("merged current")
+ continue
+ if not project_path.exists():
+ copy_leaf(harmony / rel ,project_path ,preview)
+ write_text(project_path ,text ,preview)
+ log.line("merged" ,rel ,"the skeleton's lines with the project's own")
+ log.tally("merged")
+
+def step_opus(project ,harmony ,project_name ,log ,preview):
+ rel = "0pus_" + project_name
+ if (project / rel).exists():
+ return
+ write_text(project / rel ,"" ,preview)
+ if not preview: shutil.copymode(harmony / "0pus_Harmony" ,project / rel)
+ log.line("added" ,rel ,"the file that marks the project root")
+ log.tally("skeleton added")
+
+def step_executable(project ,plan ,file_list ,log ,preview):
+ """
+ Give each EXECUTABLE path execute permission wherever it has read
+ permission. In a preview the file is found at its place before the move.
+ """
+ before = {moved_path(rel ,plan["MOVE"]) or rel: rel for rel in file_list}
+ for rel in plan["EXECUTABLE"]:
+ path = project / rel
+ if not path.is_file():
+ path = project / before.get(rel ,rel)
+ if not path.is_file():
+ log.error(rel ,"EXECUTABLE names a file that does not exist")
+ continue
+ mode = path.stat().st_mode
+ wanted = mode | ((mode & 0o444) >> 2)
+ if wanted == mode:
+ continue
+ if not preview: os.chmod(path ,wanted)
+ log.line("made executable" ,rel)
+ log.tally("made executable")
+
+def step_ignored(project ,file_list ,ignored_list ,log):
+ """
+ List ignored files outside scratchpad and consumer, by the highest
+ directory holding nothing git tracks.
+ """
+ tracked_dir = directory_set(file_list)
+ group = {}
+ for rel in ignored_list:
+ part = rel.split("/")
+ if "scratchpad" in part[:-1] or part[0] == "consumer" or any(p in CACHE_DIR_NAME for p in part):
+ continue
+ top = rel
+ parent = Path(rel).parent
+ while parent != Path(".") and parent.as_posix() not in tracked_dir:
+ top = parent.as_posix() + "/"
+ parent = parent.parent
+ group[top] = group.get(top ,0) + 1
+ for top ,n in sorted(group.items()):
+ log.line("left alone" ,top ,f"ignored by git; {n} file{'s' if n != 1 else ''}")
+ log.tally("left alone" ,n)
+
+def step_newly_ignored(project ,ignored_list ,log):
+ """
+ A file the run placed where the merged .gitignore ignores it would not be
+ recorded by 'git add -A'. Such files are reported as errors.
+ """
+ before = set(ignored_list)
+ for rel in ignored_file_list(project):
+ part = rel.split("/")
+ if rel in before or "scratchpad" in part[:-1] or part[0] == "consumer" or any(p in CACHE_DIR_NAME for p in part):
+ continue
+ log.error(rel ,"git ignores it now, so 'git add -A' will not record it; add it with 'git add -f'")
+
+def step_empty_dir(project ,log ,preview):
+ for dirpath ,dirnames ,filenames in os.walk(project ,topdown=False):
+ path = Path(dirpath)
+ if path == project or ".git" in path.relative_to(project).parts:
+ continue
+ try:
+ remaining = list(path.iterdir())
+ except FileNotFoundError:
+ continue
+ if not remaining:
+ rel = path.relative_to(project).as_posix()
+ if not preview: path.rmdir()
+ log.line("removed" ,rel + "/" ,"empty directory")
+ log.tally("directory removed")
+
+def work(harmony ,project ,plan ,preview):
+ print(f"skeleton_update {'preview ' if preview else ''}run in {project}")
+ print(f" skeleton from {harmony}")
+ log = Log(preview)
+ project_name = project.name
+
+ skeleton_list = skeleton_file_list(harmony)
+ skeleton_set = set(skeleton_list)
+ file_list = [r for r in project_file_list(project) if not any(p in CACHE_DIR_NAME for p in r.split("/"))]
+ ignored_list = [r for r in ignored_file_list(project) if not any(p in CACHE_DIR_NAME for p in r.split("/"))]
+
+ step_cache(project ,log ,preview)
+ step_sort(project ,plan ,skeleton_set ,file_list ,ignored_list ,log ,preview)
+ step_skeleton(project ,harmony ,skeleton_list ,plan ,log ,preview)
+ step_merge(project ,harmony ,project_name ,plan ,log ,preview)
+ step_opus(project ,harmony ,project_name ,log ,preview)
+ step_executable(project ,plan ,file_list ,log ,preview)
+ step_ignored(project ,file_list ,ignored_list ,log)
+ if preview:
+ print(" (directories left empty are found and removed by run, not listed in a preview)")
+ else:
+ step_empty_dir(project ,log ,preview)
+ step_newly_ignored(project ,ignored_list ,log)
+
+ log.summary()
+ return 1 if log.error_count else 0
+
+
+# --------------------------------------------------------------------------------
+# command line
+
+def read_plan(path):
+ plan = {"MOVE": {} ,"DEPRECATE": () ,"DUPLICATE": () ,"KEEP": () ,"EXECUTABLE": ()}
+ if path is None:
+ return plan
+ given = runpy.run_path(str(path))
+ for name in PLAN_NAME:
+ if name in given:
+ plan[name] = given[name]
+ if not isinstance(plan["MOVE"] ,dict):
+ raise ValueError("MOVE must be a dictionary of old path to new path")
+ for name in ("DEPRECATE" ,"DUPLICATE" ,"KEEP" ,"EXECUTABLE"):
+ plan[name] = tuple(plan[name])
+ for old ,new in plan["MOVE"].items():
+ if old.endswith("/") != new.endswith("/"):
+ raise ValueError(f"MOVE '{old}' to '{new}': a directory moves to a directory, both ending in /")
+ return plan
+
+def print_help():
+ print(__doc__.strip())
+ return 0
+
+def CLI(argv=None):
+ if argv is None: argv = sys.argv[1:]
+ if not argv or argv[0] in ("help" ,"-h" ,"--help"): return print_help()
+
+ preview = argv[0] == "preview"
+ if preview: argv = argv[1:]
+ if not argv:
+ print("Error: 'preview' needs a command after it." ,file=sys.stderr)
+ return 1
+
+ command ,rest = argv[0] ,argv[1:]
+ if command != "run":
+ print(f"Error: unknown command '{command}'." ,file=sys.stderr)
+ print_help()
+ return 1
+ if len(rest) not in (2 ,3):
+ print("Error: run is given []." ,file=sys.stderr)
+ return 1
+
+ harmony = Path(rest[0]).resolve()
+ project = Path(rest[1]).resolve()
+ plan_path = Path(rest[2]).resolve() if len(rest) == 3 else None
+
+ if not (harmony / "0pus_Harmony").is_file() or not (harmony / ".git").exists():
+ print(f"Error: {harmony} is not a Harmony checkout: it lacks 0pus_Harmony or .git." ,file=sys.stderr)
+ return 1
+ if not (project / ".git").exists():
+ print(f"Error: {project} is not a git work tree; the tool sorts the files git tracks." ,file=sys.stderr)
+ return 1
+ if harmony == project:
+ print("Error: the project is the Harmony checkout itself." ,file=sys.stderr)
+ return 1
+ if plan_path and not plan_path.is_file():
+ print(f"Error: plan file {plan_path} does not exist." ,file=sys.stderr)
+ return 1
+ try:
+ plan = read_plan(plan_path)
+ except Exception as error:
+ print(f"Error: plan file {plan_path}: {error}" ,file=sys.stderr)
+ return 1
+
+ dirty = git_dirty(project)
+ if dirty and not preview:
+ print("Error: the project work tree has uncommitted changes. Commit them first:" ,file=sys.stderr)
+ print(dirty ,file=sys.stderr)
+ return 1
+ dirty = git_dirty(harmony)
+ if dirty:
+ print("Error: the Harmony checkout has uncommitted changes; the skeleton is taken" ,file=sys.stderr)
+ print("from its last commit, and its files would not match it:" ,file=sys.stderr)
+ print(dirty ,file=sys.stderr)
+ return 1
+
+ return work(harmony ,project ,plan ,preview)
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(CLI())
+ except BrokenPipeError:
+ os.dup2(os.open(os.devnull ,os.O_WRONLY) ,sys.stdout.fileno())
+ sys.exit(1)
diff --git a/developer/authored/.gitkeep b/developer/authored/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tester/scratchpad/.gitignore b/developer/authored/deprecated/tester/scratchpad/.gitignore
similarity index 100%
rename from tester/scratchpad/.gitignore
rename to developer/authored/deprecated/tester/scratchpad/.gitignore
diff --git a/developer/design_AccountTree.html b/developer/design_AccountTree.html
new file mode 100644
index 0000000..8866744
--- /dev/null
+++ b/developer/design_AccountTree.html
@@ -0,0 +1,761 @@
+
+
+
+
+ AccountTree 5.0 Design
+
+
+
+
+
+
+
+
diff --git a/document/design_AccountTree.html b/document/design_AccountTree.html
index e4ec119..6b496b0 100644
--- a/document/design_AccountTree.html
+++ b/document/design_AccountTree.html
@@ -24,7 +24,7 @@
@@ -72,7 +72,7 @@
- Every account is entered through its parent. Root enters its child accounts through the system's login programs, the display manager, login at the console and sshd, which run as root and start a session for a person who gives a password or a key. A login account enters its child accounts through the AccountTree server, which starts sessions and programs in them; an account below a login account has no password, and the login programs do not enter it. The rule is the same at every level of the tree. Only the means differ, and the means for root's children are the ones Linux already has.
+ Every account is entered through its parent. Root enters its child accounts through the system's login programs, the display manager, login at the console and sshd, which run under root and start a session for a person who gives a password or a key. A login account enters its child accounts through the AccountTree server, which starts sessions and programs in them; an account below a login account has no password, and the login programs do not enter it. The rule is the same at every level of the tree. Only the means differ, and the means for root's children are the ones Linux already has.
@@ -102,8 +102,8 @@
account specifier: the name of an account in one place: system <host name> <system account>, or storage <tree dir> <storage account>.
same account: two account specifiers that name one account in two places. The system map records which system accounts are the same accounts as which storage accounts.
view: an idmapped mount through which a parent account sees a child account's files as its own.
- server: the program that changes accounts, databases and mounts on behalf of persons. It runs as an unprivileged account of its own, AccountTree, and makes every change to the system through journal interpreters.
- journal interpreter: a small privileged program of Man-In-Grey's design that receives a journal of commands, has its guard check every command against its constraints, and only then executes them. AccountTree has one bespoke journal interpreter for each of its command languages. design_Man-In-Grey.html gives the design.
+ server: the program that changes accounts, databases and mounts on behalf of persons. It runs under an unprivileged account of its own, AccountTree, and makes every change to the system through journal interpreters.
+ journal interpreter: a small program of Man_In_Grey's design, run under root through its gate, that receives a journal of commands, has its guard check every command against its constraints, and only then executes them. AccountTree has one bespoke journal interpreter for each of its command languages. Man_In_Grey.html gives the design.
unmanaged account: an account AccountTree does not manage, such as root, a service's account, or an account made with useradd by hand.
@@ -255,7 +255,7 @@
- At attach, the database uids of the storage are to be compared with the system uids in use, from the system's account database, which includes directory services, and from /etc/subuid. Each coincidence is logged with the system account that would appear to own the raw files. A coincidence is not refused: through the mounts each account sees its own files correctly, and the raw files are behind the tree dir, which only the server's account and root can enter. It is reported because a tool that works by uid on the raw files, run as root, would treat them as that account's. This check is not yet built.
+ At attach, the database uids of the storage are to be compared with the system uids in use, from the system's account database, which includes directory services, and from /etc/subuid. Each coincidence is logged with the system account that would appear to own the raw files. A coincidence is not refused: through the mounts each account sees its own files correctly, and the raw files are behind the tree dir, which only the server's account and root can enter. It is reported because a tool that works by uid on the raw files, run under root, would treat them as that account's. This check is not yet built.
@@ -407,7 +407,7 @@
- The server owns AccountTree's records: the system database, /var/lib/AccountTree/system.sqlite3, and on each storage the tree dir and its storage database. It reads and writes them with its own rights. Every change to the system itself, an account in /etc/passwd, a home directory owned by a database uid, a mount, a session in another account, needs privilege the server does not have. For those the server composes a journal and hands it to a bespoke journal interpreter, through the interpreter's gate, following the design of Man-In-Grey.
+ The server owns AccountTree's records: the system database, /var/lib/AccountTree/system.sqlite3, and on each storage the tree dir and its storage database. It reads and writes them with its own rights. Every change to the system itself, an account in /etc/passwd, a home directory owned by a database uid, a mount, a session in another account, needs privilege the server does not have. For those the server composes a journal and hands it to a bespoke journal interpreter, through the interpreter's gate, following the design of Man_In_Grey.
@@ -421,7 +421,7 @@
- The requesting account is the principal of every journal the server composes for the request. A journal interpreter sees the server's account as its caller, and accepts a principal named in a journal only because the server's account is listed, in a table root owns, as entitled to name one. So the decision about who asked rests on the kernel at the socket, and the decision about what is allowed rests on the journal interpreter's guardrails.
+ The requesting account is named in every journal the server composes for the request. A journal interpreter sees the server's account as the orchestrator account, and accepts a requesting account named in a journal only because the server's account is listed in relay_account, in constraints root owns. So the decision about who asked rests on the kernel at the socket, and the decision about what is allowed rests on the journal interpreter's constraints.
Requests
@@ -442,7 +442,7 @@
- The administrator's commands are not requests. AccountTree_admin runs as root, so it can change the system directly, but it composes the same journals and hands them to the same journal interpreters, with root as caller and principal. The code that changes the system then lives in one place, the journal interpreters, and the constraints catch the administrator's mistakes as they catch the server's. Making accounts the same, attaching, detaching and setting limits work while the server is stopped, at boot for instance. The server and AccountTree_admin both write the databases; SQLite's lock lets one transaction in at a time.
+ The administrator's commands are not requests. AccountTree_admin runs under root, so it can change the system directly, but it composes the same journals and hands them to the same journal interpreters, with root as the orchestrator account and no requesting account named. The code that changes the system then lives in one place, the journal interpreters, and the constraints catch the administrator's mistakes as they catch the server's. Making accounts the same, attaching, detaching and setting limits work while the server is stopped, at boot for instance. The server and AccountTree_admin both write the databases; SQLite's lock lets one transaction in at a time.
Command languages and constraints
@@ -455,11 +455,11 @@
Account. Commands: make a system account with a given name and system uid; delete a system account; delete its group. Constraints: the name is a valid account name; a new system uid lies in AccountTree's range and appears in no row of the system map; a system account is deleted only when the system map records that AccountTree made it. Edits /etc/passwd, /etc/shadow, /etc/group and /etc/gshadow.
Home. Commands: make a storage account's home in account_data, owned by its database uid, mode 0700; delete a home and its files. Constraints: the tree dir holds a storage database whose database uuid the system database knows, and the storage account exists there with that database uid; a home is deleted only during the deletion of its storage, or of a deallocated account.
Mount. Commands: make and delete a mount point; mount and unmount a home; mount and unmount a view. Constraints: a home is mounted only from <tree dir>/account_data/<storage account> onto /home/<system account>, where a row of the system map in use pairs the two, with that row's uid translation; a view is mounted only of a child account's home, at ~/account_child/<short name> of its parent; every mount is nosuid and nodev; a mount point is an empty directory owned by root with mode 0000, made and deleted only at /home/<system account>.
- Session. Commands: start a session, or a program, in an account. Constraint: the principal is above the account in the tree.
+ Session. Commands: start a session, or a program, in an account. Constraint: the requesting account is above the account in the tree.
- Each command language needs a different part of root's rights: the account language to write files in /etc, the mount language to mount, the session language to change uid. Separate journal interpreters let each hold only its own part, as Man-In-Grey's design on least privilege describes.
+ Each command language needs a different part of root's rights: the account language to write files in /etc, the mount language to mount, the session language to change uid. Separate journal interpreters let each hold only its own part, as Man_In_Grey's design on least privilege describes.
Partial failure
@@ -482,7 +482,7 @@
- Options that give the service a mount namespace of its own, such as PrivateMounts or ProtectHome. The journal interpreters the server starts inherit its mount namespace, and a mount made there exists only for the service; no account ever sees it.
- - NoNewPrivileges, and the options that imply it for a service not running as root. With it set, the kernel ignores the setuid bit and file capabilities of every program the service starts, so the gates run without privilege.
+ - NoNewPrivileges, and the options that imply it for a service not running under root. With it set, the kernel ignores the setuid bit and file capabilities of every program the service starts, so the gates run without privilege.
Limits
@@ -545,7 +545,7 @@
- An account below a login account is entered through its parent: the parent sends the server an enter request, and the server composes a journal for the session journal interpreter, whose constraint allows the session only when the principal is above the account in the tree. Whether such an account is ever to be entered through the login programs as well is open.
+ An account below a login account is entered through its parent: the parent sends the server an enter request, and the server composes a journal for the session journal interpreter, whose constraint allows the session only when the requesting account is above the account in the tree. Whether such an account is ever to be entered through the login programs as well is open.
@@ -558,7 +558,7 @@
AccountTree: the person's program. Its commands send requests to the server; its window command opens the resource window, which shows the account's child accounts and their states and sends the same requests. Runs as the person's account.
AccountTree_server: the server, started by its systemd unit as the account AccountTree. Nobody types it.
AccountTree_admin: the administrator's program. Root. Its commands: system make, system show and system delete; storage make, storage show and storage delete, each given a tree dir; account make; is-same-account, is-not-same-account, same-account show and same-account suggest; attach and detach; and limit set. At boot, before logins are allowed, attach is run for every storage present, the disk inside the machine included, so that /home is populated. Detaching is refused while a system account it would unmount has a session. Unlocking an encrypted storage and mounting it come later.
- The bespoke journal interpreters, one for each command language, account, home, mount and session, each a compiled gate followed by a guard and Man-In-Grey's general interpreter. Installed owned by root, in directories only root can write.
+ The bespoke journal interpreters, one for each command language, account, home, mount and session, each a compiled gate followed by a guard and Man_In_Grey's general interpreter. Installed owned by root, in directories only root can write.
home_present: the PAM check.
The schema and initialization scripts.
@@ -642,7 +642,7 @@
- The AccountTree server already runs as a service, and is a natural home for a background pass over attached storage. The pass keeps an index of content hashes, gives the kernel call the pairs whose hashes match, and so keeps each storage compacted as it is used. On a file system without shared extents, the pass reports duplicates and changes nothing. The same index serves integration in 7.0.
+ The AccountTree server already runs under a service, and is a natural home for a background pass over attached storage. The pass keeps an index of content hashes, gives the kernel call the pairs whose hashes match, and so keeps each storage compacted as it is used. On a file system without shared extents, the pass reports duplicates and changes nothing. The same index serves integration in 7.0.
Deep freeze
diff --git a/setup b/setup
index 1992fea..d2a452a 100755
--- a/setup
+++ b/setup
@@ -3,11 +3,24 @@
import os
import sys
+import json
import argparse
import tempfile
PROJECT_ROLES = ["administrator", "consumer", "developer", "tester"]
+# Role shells stack: setup run inside a role shell starts a shell in the new
+# role, and exiting it returns to the shell it was started from. A loop
+# through setup, such as one started from ~/.bashrc, is stopped at this depth.
+DEPTH_MAX = 8
+
+# The environment setup was first run from, before any role, recorded so that
+# every role shell starts from it rather than from the role shell around it.
+BASE_KEY = "RT_SETUP_BASE"
+
+# The outer role's values, for handing an Emacs shell buffer back on exit.
+OUTER_KEY = ("REPO_HOME", "PROJECT", "ROLE", "PATH")
+
def main():
parser = argparse.ArgumentParser(
description="Enter a project role environment by spawning an isolated subshell."
@@ -15,19 +28,35 @@ def main():
parser.add_argument("role", choices=PROJECT_ROLES, help="The project role to assume.")
args = parser.parse_args()
- env = os.environ.copy()
+ outer = os.environ.copy()
- # A role shell started inside another role shell inherits the outer
- # project's PATH, REPO_HOME and functions, and a loop through setup would
- # start shells without end. RT_SETUP_DEPTH counts the role shells this
- # process is inside; any nesting is refused.
- depth = env.get("RT_SETUP_DEPTH", "0")
+ # A role shell started inside another would otherwise inherit the outer
+ # role's environment: its tool directory on PATH, its variables, and the
+ # shell functions the shared setup exports. So every role shell starts
+ # from the base environment, the one setup was first run from, however
+ # deeply role shells are stacked. RT_SETUP_DEPTH counts the role shells
+ # this process is inside.
+ depth = outer.get("RT_SETUP_DEPTH", "0")
depth = int(depth) if depth.isdigit() else 0
- if depth > 0:
- print(f"setup: already inside a role shell ({env.get('PROJECT', '?')} {env.get('ROLE', '?')}).")
- print("setup: type 'exit' to leave it, then run setup again.")
+ if depth >= DEPTH_MAX:
+ print(f"setup: already {depth} role shells deep; refusing, to stop a loop through setup.")
sys.exit(1)
+
+ base = None
+ if depth > 0 and BASE_KEY in outer:
+ try:
+ base = json.loads(outer[BASE_KEY])
+ except ValueError:
+ base = None
+ if not isinstance(base, dict):
+ base = {k: v for k, v in outer.items() if not k.startswith("RT_SETUP_")}
+
+ env = dict(base)
+ env[BASE_KEY] = json.dumps(base)
env["RT_SETUP_DEPTH"] = str(depth + 1)
+ if depth > 0:
+ for key in OUTER_KEY:
+ env[f"RT_SETUP_OUTER_{key}"] = outer.get(key, "")
# Python-domain logic: Variables and Paths
# The project is the directory holding this file, whatever REPO_HOME the
@@ -67,6 +96,11 @@ def main():
'fi',
'if [[ -n "${INSIDE_EMACS}" ]] && command -v env_to_emacs >/dev/null 2>&1; then',
' env_to_emacs',
+ ' # inside another role shell: on exit, hand the Emacs buffer back to the outer role',
+ ' if [[ -n "${RT_SETUP_OUTER_ROLE:-}" ]]; then',
+ ' rt_setup_env_to_emacs="$(command -v env_to_emacs)"',
+ ' trap \'REPO_HOME="$RT_SETUP_OUTER_REPO_HOME" PROJECT="$RT_SETUP_OUTER_PROJECT" ROLE="$RT_SETUP_OUTER_ROLE" PATH="$RT_SETUP_OUTER_PATH" "$rt_setup_env_to_emacs"\' EXIT',
+ ' fi',
'fi',
'rm -f "${BASH_SOURCE[0]}"'
]
diff --git a/shared/project-dependency/Python.org b/shared/project-dependency/Python.org
index 4799f37..8e67c79 100644
--- a/shared/project-dependency/Python.org
+++ b/shared/project-dependency/Python.org
@@ -1,74 +1,13 @@
-#+TITLE: Python for SubU
+#+TITLE: Python Installation Guide
#+AUTHOR: Project Administrator
-SubU's tools are Python programs, and its window is drawn with GTK 4 through PyGObject, the GTK bindings for Python. GTK talks to the desktop's display directly, Wayland or X, as the desktop's own programs do.
+This project requires a dedicated Python environment. The configuration plugin (`shared/project-dependency/Python.init`) was written targeting Python 3.12.3, though the architecture does not rely on any known version-dependent features.
-There are two ways to provide the Python. Choose one.
+The build below can be moved. Nothing in it records the absolute directory it was built in where the interpreter depends on it, so the directory can move with a home directory, or to another machine, and keep working.
- - The system Python. Least work: install two packages, section 1, and stop there.
- - A project Python, built from source as a sibling of the project and linked into it. The project then does not depend on the system's Python version. Build the vanilla Python of section 3 first, then add what SubU needs, section 2.
+* 1. Install the development libraries
-The role setup decides which one a shell gets. shared/project-dependency/Python.init puts the project Python at the front of PATH when shared/project-dependency/Python-3.12.3 leads to a build that runs. When there is no such build, or it does not run, python3 is the system's. Running the tools with the system Python therefore needs nothing more than not installing the project Python.
-
-The package names below are for openSUSE; zypper search finds the name of one that differs on another release, and other distributions name the same libraries similarly.
-
-* 1. Using the system Python
-
-Install PyGObject for the system Python, and the GTK 4 introspection data. Introspection data is the machine-readable description of GTK's interface, which PyGObject reads at run time to call into GTK.
-
-#+begin_src bash
-sudo zypper install python313-gobject typelib-1_0-Gtk-4_0
-#+end_src
-
-The 313 in the first name is the system Python's version, 3.13; find yours with python3 --version.
-
-Check:
-
-#+begin_src bash
-python3 -c 'import gi; gi.require_version("Gtk" ,"4.0"); from gi.repository import Gtk; print("GTK" ,Gtk.get_major_version())'
-#+end_src
-
-* 2. SubU's additions to the project Python
-
-A Python built from source has only its standard library. The system's PyGObject cannot be borrowed: it is installed in the system Python's own module directory, and is compiled for that Python's version. The project Python gets its own copy, compiled by pip against the GTK libraries already on the system.
-
-** 2.1 System development files
-
-pip compiles PyGObject and pycairo, the Python bindings for cairo, the drawing library GTK uses. Compiling needs the C development files of both libraries; the libraries themselves are already present on any desktop that runs GTK programs. The GTK 4 introspection data is read when the window opens.
-
-#+begin_src bash
-sudo zypper install cairo-devel gobject-introspection-devel typelib-1_0-Gtk-4_0
-#+end_src
-
-** 2.2 Install PyGObject
-
-Run this in a role shell, so that python3 is the project Python, and with the Python's pkg-config files moved aside for the length of the install:
-
-#+begin_src bash
-mv shared/project-dependency/Python-3.12.3/lib/pkgconfig shared/project-dependency/Python-3.12.3/lib/pkgconfig.off
-python3 -m pip install PyGObject
-mv shared/project-dependency/Python-3.12.3/lib/pkgconfig.off shared/project-dependency/Python-3.12.3/lib/pkgconfig
-#+end_src
-
-Why the move. The build system pycairo uses, Meson, asks pkg-config where Python's header files are, and pkg-config answers from lib/pkgconfig/python-3.12.pc. When the Python's path holds a character outside ASCII, such as the middle dot of a SubU home directory (/home/Thomas·developer), pkg-config prints that character with a backslash before each of its bytes, which is no longer valid UTF-8, and Meson stops with "'utf-8' codec can't decode byte 0xc2 ... invalid continuation byte". With the .pc files out of sight, Meson asks the Python interpreter itself, and its answer is correct. The .pc files serve only C programs that build against Python, so moving them aside for the install affects nothing else. On a path of plain ASCII the move is unnecessary but harmless.
-
-If a build stops with "Dependency ... found: NO", the development files of that library are missing; install its -devel package and run pip again.
-
-** 2.3 Check
-
-#+begin_src bash
-python3 -c 'import gi; gi.require_version("Gtk" ,"4.0"); from gi.repository import Gtk; print("GTK" ,Gtk.get_major_version())'
-#+end_src
-
-* 3. The vanilla project Python
-
-This is the Harmony recipe for a project Python, unchanged. It is written for Python 3.12.3, though nothing in the project depends on version-specific features.
-
-The build can be moved. Nothing it depends on records the absolute directory it was built in, so the directory can move with a home directory, or to another machine with compatible system libraries, and keep working.
-
-** 3.1 Install the development libraries
-
-Python's configure step looks for the development files of each optional library, and silently leaves out any module whose library it does not find. A build without sqlite3-devel, for example, has no sqlite3 module, and the gap shows only when a program imports it. Install these first.
+Python's configure step looks for the development files of each optional library, and silently leaves out any module whose library it does not find. A build without sqlite3-devel, for example, has no sqlite3 module, and the gap shows only when a program imports it. Install these first. The names are for openSUSE; zypper search finds the name of one that differs.
#+begin_src bash
sudo zypper install gcc make sqlite3-devel libopenssl-devel libffi-devel zlib-devel \
@@ -77,9 +16,10 @@ sudo zypper install gcc make sqlite3-devel libopenssl-devel libffi-devel zlib-de
zlib-ng-compat-devel serves in place of zlib-devel, and the two conflict; if zypper reports that conflict, keep the one already installed.
-** 3.2 Download and extract
+tk-devel gives the tkinter module, which the window programs use.
-Download the Python source code and extract it as a sibling of the project directory. From the top of the project:
+* 2. Download and Extract
+Download the Python source code and extract it as a sibling to the main project directory. Assuming you are at the top level of your project workspace:
#+begin_src bash
cd ../
@@ -88,9 +28,8 @@ tar -xzf Python-3.12.3.tgz
cd Python-3.12.3
#+end_src
-** 3.3 Build
-
-Compile in place. Setting the installation prefix to the current directory isolates the build.
+* 3. Build the Environment
+Compile the Python binaries in place. Setting the installation prefix to the current directory isolates the build.
#+begin_src bash
CONFIG_SITE= ./configure --prefix="$(pwd)" --libdir="$(pwd)/lib" --enable-optimizations
@@ -106,14 +45,14 @@ The build is static: the interpreter is one program, with no separate libpython3
Near the end of build.log, make lists any optional modules it could not build, under "The necessary bits to build these optional modules were not found". nis, an obsolete network directory service, and _curses_panel may appear there; the project uses neither. For any other module listed, install the missing development library and build again.
-** 3.4 Check the build
+* 4. Check the build
#+begin_src bash
bin/python3 -c 'import sys ,ssl ,sqlite3 ,ctypes ,lzma ,bz2 ,readline ,tkinter; print(sys.prefix)'
ldd bin/python3 | grep libpython # prints nothing: no shared libpython
#+end_src
-** 3.5 Use pip through the interpreter
+* 5. Use pip through the interpreter
The scripts make install writes into bin/, such as pip3, start with a #! line naming the absolute path of the interpreter, so they break when the directory moves. Run pip as a module instead, which uses whichever interpreter runs it:
@@ -121,8 +60,12 @@ The scripts make install writes into bin/, such as pip3, start with a #! line na
python3 -m pip install
#+end_src
-** 3.6 Link the project
+* 6. Link the Project
+
+Python-3.12.3 is linked to a Harmony derived project by default.
+
+See administrator/document/how-to_add-project-dependency.html for details about linking a project, if you want to check the setup.
-The project reaches the build through shared/project-dependency/Python-3.12.3, a link to project/Python-3.12.3, where project is the link to the directory holding this project and its siblings. A build extracted as a sibling of the project, as in 3.2, is found without further steps.
+If you are installing a different version, then at least the Python-3.12.3 version project linking is there as a good example.
-See administrator/document/how-to_add-project-dependency.html for how dependencies are linked, or to link a different version.
+Project linking is not difficult, make a sym link to `project/`, and then add an init file, as described in administrator/document/how-to_add-project-dependency.html. The 'project' in the link is literal.
diff --git a/shared/tool/env_to_emacs b/shared/tool/env_to_emacs
index 947efc0..8a65dba 100755
--- a/shared/tool/env_to_emacs
+++ b/shared/tool/env_to_emacs
@@ -1,30 +1,78 @@
#!/usr/bin/env bash
+# env_to_emacs - give the Emacs shell buffer this setup runs in the role's environment
+#
+# Emacs starts programs (compile, grep, and the like) with its own copy of the
+# environment, which a shell running inside it cannot change. This tool sets
+# REPO_HOME, PROJECT, ROLE and PATH in Emacs, and exec-path to match, for the
+# one shell buffer whose shell this setup runs under, and for no other.
+#
+# The values are buffer-local. Killing the buffer discards them, and other
+# buffers, including shells started later, keep Emacs's own environment. (An
+# earlier version set them globally, so every shell started afterward began
+# in the last role set up, even after that role's buffer was gone.)
+#
+# The buffer is found by process: Emacs knows the process id of the shell in
+# each shell buffer, and that shell is an ancestor of this script, perhaps
+# several generations up, since setup starts a new bash under the buffer's own.
+
script_afp=$(realpath "${BASH_SOURCE[0]}")
set -euo pipefail
if [ -z "${REPO_HOME:-}" ]; then
- echo "emacs-sync:: error: REPO_HOME is not set. Source a setup environment first."
+ echo "env_to_emacs: error: REPO_HOME is not set. Source a setup environment first."
exit 1
fi
if ! command -v emacsclient >/dev/null 2>&1; then
- echo "emacs-sync:: error: emacsclient is not installed or not in PATH."
+ echo "env_to_emacs: error: emacsclient is not installed or not in PATH."
exit 1
fi
-# Push variables to the Emacs global environment
-emacsclient --eval "(setenv \"REPO_HOME\" \"$REPO_HOME\")" > /dev/null
-emacsclient --eval "(setenv \"PROJECT\" \"$PROJECT\")" > /dev/null
+# quote a value as an Emacs Lisp string
+elisp_string(){
+ local s="$1"
+ s=${s//\\/\\\\}
+ s=${s//\"/\\\"}
+ printf '"%s"' "$s"
+}
-if [ -n "${ROLE:-}" ]; then
- emacsclient --eval "(setenv \"ROLE\" \"$ROLE\")" > /dev/null
-fi
+form=$(cat < pid 1) (not buffer))
+ (setq buffer
+ (seq-find (lambda (b)
+ (let ((p (get-buffer-process b)))
+ (and p (eql (process-id p) pid))))
+ (buffer-list)))
+ (unless buffer
+ (setq pid (alist-get 'ppid (process-attributes pid)))))
+ (if (not buffer)
+ "no shell buffer found"
+ (with-current-buffer buffer
+ (setq-local process-environment (copy-sequence process-environment))
+ (setenv "REPO_HOME" $(elisp_string "$REPO_HOME"))
+ (setenv "PROJECT" $(elisp_string "${PROJECT:-}"))
+ (setenv "ROLE" $(elisp_string "${ROLE:-}"))
+ (setenv "PATH" $(elisp_string "$PATH"))
+ (setq-local exec-path (append (parse-colon-path $(elisp_string "$PATH")) (list exec-directory)))
+ (buffer-name))))
+EOF
+)
-# Sync the PATH so Emacs subprocesses find the local tools
-emacsclient --eval "(setenv \"PATH\" \"$PATH\")" > /dev/null
+if ! result=$(emacsclient --eval "$form" 2>&1); then
+ echo "env_to_emacs: cannot reach the Emacs server; Emacs environment left unchanged"
+ echo " $result"
+ exit 0
+fi
+result=${result#\"}
+result=${result%\"}
-# Update exec-path for native Emacs executable resolution
-emacsclient --eval "(setq exec-path (append (parse-colon-path \"$PATH\") (list exec-directory)))" > /dev/null
+if [[ "$result" == "no shell buffer found" ]]; then
+ echo "env_to_emacs: no Emacs shell buffer runs this shell; Emacs environment left unchanged"
+ exit 0
+fi
-echo "Emacs environment synchronized for project: $PROJECT"
+echo "Emacs buffer $result has the environment of $PROJECT ${ROLE:-}"
diff --git a/shared/tool/makefile/Harmony.mk b/shared/tool/makefile/Harmony.mk
index eaf1ce8..b168ac5 100644
--- a/shared/tool/makefile/Harmony.mk
+++ b/shared/tool/makefile/Harmony.mk
@@ -30,7 +30,9 @@ MACHINE_DIR ?= scratchpad/made
LIBRARY_FILE ?= $(LIBRARY_DIR)/lib$(LIBRARY_NAME).a
-LN_FLAGS ?= -L$(LIBRARY_DIR) -L/lib64 -L/lib -l$(LIBRARY_NAME)
+# the project library is linked only when the namespace makes one
+LN_FLAGS ?= -L$(LIBRARY_DIR) -L/lib64 -L/lib $(if $(object_lib),-l$(LIBRARY_NAME))
+# LN_FLAGS ?= -L$(LIBRARY_DIR) -L/lib64 -L/lib -l$(LIBRARY_NAME)
KMOD_SOURCE_DIR ?= authored
KMOD_CCFLAGS ?= -I $(KMOD_SOURCE_DIR)
diff --git a/shared/tool/setup b/shared/tool/setup
index 5ecb239..7dc24d3 100644
--- a/shared/tool/setup
+++ b/shared/tool/setup
@@ -74,6 +74,8 @@ umask 0077
PATH="$REPO_HOME/shared/authored:$PATH"
# Remove duplicates, keeping the first occurrence
+ # Done with bash built-ins only. A pipeline through awk and sed left PATH
+ # empty whenever either program failed to run.
clean_path(){
local entry result=""
local -A seen=()
diff --git a/shared/tool/version b/shared/tool/version
index b264028..68524a3 100755
--- a/shared/tool/version
+++ b/shared/tool/version
@@ -1,2 +1,4 @@
echo "Harmony v3.5 2026-10-14 04:51:11 Z"
+
+
echo "SubU v4.0 2026-10-14 04:51:11 Z"
--
2.20.1