From 92747002461e0ccd42063ac9afd5b860f5f35af5 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 22:17:13 +0200 Subject: [PATCH 01/17] App.__get_project_refs(): Skip children for Scope.Self __get_project_refs() appends the values of a node to the result buffer when the scope is Scope.Self. That is wrong: Scope.Self means "no children", so a node visited with Scope.Self is supposed to contribute only itself (if add_self is set), never its children. A previous refactor that turned the scope handling into a match/case block accidentally changed that, and as a consequence get_project_refs() with scope=Scope.One now returns the direct dependencies plus their dependencies. The second-level entries are raw values: they are not stripped of whitespace, and they are not reduced to module names even when names_only is True. The spurious self-edges this injects into the dependency graph built by __read_dep_graph() make find_circular_deps() report cycles that do not exist in the direct dependency graph. Restore the original semantics by not recursing into children when the scope is Scope.Self. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/App.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/python/jw/pkg/App.py b/src/python/jw/pkg/App.py index 7abc3040..cffb1972 100644 --- a/src/python/jw/pkg/App.py +++ b/src/python/jw/pkg/App.py @@ -199,25 +199,24 @@ class App(Base): ), ) vals_list = vals.split(',') if vals else [] - match scope: - case Scope.Self: - buf += vals_list - case Scope.One | Scope.Subtree: - subscope = scope.Self if scope == Scope.One else scope - for val in vals_list: - val = val.strip() - if not (len(val)): - continue - self.__get_project_refs( - buf, - visited, - val, - section, - key, - add_self = True, - scope = subscope, - names_only = names_only, - ) + if scope != Scope.Self: + # Scope.Self adds the node itself (if add_self), but not its + # children, so stop recursing here + subscope = scope.Self if scope == Scope.One else scope + for val in vals_list: + val = val.strip() + if not (len(val)): + continue + self.__get_project_refs( + buf, + visited, + val, + section, + key, + add_self = True, + scope = subscope, + names_only = names_only, + ) if add_self: buf.append(spec) -- 2.55.0 From 59998ae00bef531fc502afb4acb19df45afc6fc5 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 22:20:06 +0200 Subject: [PATCH 02/17] App.__find_circular_deps(): Fix cycle path __find_circular_deps_recursive() builds the cycle path by inserting each visited dependency at the front of the list on the way back up, and __find_circular_deps() appends the project where the cycle was detected at the end. The path therefore starts at the first child of the DFS root instead of at the project that closes the cycle, and the closing project appears twice: for a dependency cycle between projects A and B, find_circular_deps() reports "B -> A -> A" instead of "A -> B -> A". Keep the DFS stack as an ordered list, and when a dependency is already on the stack, return the part of the stack from that dependency to the top, plus the dependency itself, which starts and ends at the same project. Reverse the result before returning, because an edge a -> b in the flipped graph means that b depends on a, so the cycle is reported in the original dependency direction. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/App.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/python/jw/pkg/App.py b/src/python/jw/pkg/App.py index cffb1972..4c9bfe0c 100644 --- a/src/python/jw/pkg/App.py +++ b/src/python/jw/pkg/App.py @@ -258,45 +258,43 @@ class App(Base): project: str, graph: Graph, unvisited: list[str], - temp: set[str], - path: list[str], - ) -> str | None: - if project in temp: + stack: list[str], + ) -> list[str] | None: + if project in stack: log(DEBUG, 'found circular dependency at project', project) - return project + idx = stack.index(project) + return stack[idx:] + [project] if project not in unvisited: return None - temp.add(project) + stack.append(project) if project in graph: for dep in graph[project]: - last = self.__find_circular_deps_recursive( - dep, graph, unvisited, temp, path + cycle = self.__find_circular_deps_recursive( + dep, graph, unvisited, stack ) - if last is not None: - path.insert(0, dep) - return last + if cycle is not None: + return cycle unvisited.remove(project) - temp.remove(project) + stack.pop() return None def __find_circular_deps(self, projects: list[str], flavours: list[str]) -> list[str]: graph: Graph = {} - ret: list[str] = [] self.__read_dep_graph(projects, flavours, graph) unvisited = list(graph.keys()) - temp: set[str] = set() flipped = self.__flip_dep_graph(graph) while unvisited: project = unvisited[0] log(DEBUG, 'Checking circular dependency of', project) - last = self.__find_circular_deps_recursive( - project, flipped, unvisited, temp, ret - ) - if last is not None: - log(DEBUG, f'Found circular dependency below {project}, last is {last}') - ret.append(last) - return ret + cycle = self.__find_circular_deps_recursive(project, flipped, unvisited, []) + if cycle is not None: + # An edge a -> b in the flipped graph means that b + # depends on a, so reverse to report the cycle in the + # original direction + cycle = list(reversed(cycle)) + log(DEBUG, f'Found circular dependency: {" -> ".join(cycle)}') + return cycle return [] def __init__(self, distro: Distro | None = None) -> None: -- 2.55.0 From 5904efa8d9a36d09ac6ea649b0f49305dde0bb7d Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 22:21:07 +0200 Subject: [PATCH 03/17] App.__read_dep_graph(): Remove redundant loop __read_dep_graph() iterates over the given sections (flavours), but the loop body does not use the loop variable: it passes the entire sections list to get_project_refs() on every iteration, so the same lookup is repeated for each section, and the recursion into the found dependencies is re-triggered (and skipped) for each of them. Remove the loop and do the lookup once per project. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/App.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/python/jw/pkg/App.py b/src/python/jw/pkg/App.py index 4c9bfe0c..15a1a1ce 100644 --- a/src/python/jw/pkg/App.py +++ b/src/python/jw/pkg/App.py @@ -231,18 +231,17 @@ class App(Base): for project in projects: if project in graph: continue - for section in sections: - deps = self.get_project_refs( - [project], - ['pkg.requires.jw'], - sections, - scope = Scope.One, - add_self = False, - names_only = True, - ) - graph[project] = set(deps) - for dep in deps: - self.__read_dep_graph([dep], sections, graph) + deps = self.get_project_refs( + [project], + ['pkg.requires.jw'], + sections, + scope = Scope.One, + add_self = False, + names_only = True, + ) + graph[project] = set(deps) + for dep in deps: + self.__read_dep_graph([dep], sections, graph) def __flip_dep_graph(self, graph: Graph) -> Graph: ret: Graph = {} -- 2.55.0 From 6136ca8e43e974a5ffb18963d1efbac50f118abe Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 22:22:05 +0200 Subject: [PATCH 04/17] App.__format_topdir(): Mention relative in error __format_topdir() accepts "absolute", "relative", "unaltered", and "make:", but the error message it raises for anything else only lists "unaltered", "absolute", and "make:", leaving out "relative". Add "relative" to the list of valid formats in the error message. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/App.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/python/jw/pkg/App.py b/src/python/jw/pkg/App.py index 15a1a1ce..bcec244e 100644 --- a/src/python/jw/pkg/App.py +++ b/src/python/jw/pkg/App.py @@ -61,7 +61,8 @@ class App(Base): if m is None: raise Exception( f'Can\'t interpret "{fmt}" as valid topdir reference, ' - 'expecting "unaltered", "absolute", or "make:"' + 'expecting "absolute", "relative", "unaltered", ' + 'or "make:"' ) return '$(' + m.group(1) + ')' -- 2.55.0 From 104c6d90406747f4cf4d7eae19f333110ceb110e Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 06:56:06 +0200 Subject: [PATCH 05/17] lib.Uri: Fix path join for empty authority __new_with_path() joins base and path with exactly one '/', assuming a trailing '/' in base is a path separator. That assumption breaks for URIs with empty authority, where scheme_plus_authority ends in '://' (e.g. 'file:///tmp/x'): new_replace_path('/etc/hosts') drops the leading slash of the path and produces 'file://etc/hosts', which parses with hostname 'etc' and path '/hosts' instead of path '/etc/hosts'. Treat a base ending in '://' separately and keep a leading slash in the path, adding one if it is missing. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/Uri.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/python/jw/pkg/lib/Uri.py b/src/python/jw/pkg/lib/Uri.py index cada582e..11faa6d6 100644 --- a/src/python/jw/pkg/lib/Uri.py +++ b/src/python/jw/pkg/lib/Uri.py @@ -156,6 +156,12 @@ class Uri: ret.__password = None if not path: return ret + if ret.__string.endswith('://'): + # -- Empty authority: the trailing '/' is part of the '://' + # separator, so a leading slash in path must be kept to stay + # an absolute path + ret.__string += path if path.startswith('/') else '/' + path + return ret if ret.__string[-1] == '/': if path[0] == '/': ret.__string += path[1:] -- 2.55.0 From e5416d8e039c87f257d91aee9495c1e7d68a1c41 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 06:57:17 +0200 Subject: [PATCH 06/17] lib.FileContext: Interpolate path in _is_dir() _is_dir() logs a DEBUG message when _stat() is not implemented and it must guess from the trailing slash whether a path is a directory. The second part of that message is a plain string rather than an f-string, so '{path}' is logged verbatim instead of the path. Make it an f-string so the path is interpolated. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/FileContext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/FileContext.py b/src/python/jw/pkg/lib/FileContext.py index 059152d7..2622e7e8 100644 --- a/src/python/jw/pkg/lib/FileContext.py +++ b/src/python/jw/pkg/lib/FileContext.py @@ -287,7 +287,7 @@ class FileContext(abc.ABC): DEBUG, ( f"{self.log_name} doesn't implement stat(), judging by trailing " - 'slash if {path} is a directory' + f'slash if {path} is a directory' ), ) return path[-1] == '/' -- 2.55.0 From 6e2f341b33a723b7f3a7128cc25b06addbc77cf9 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 06:57:57 +0200 Subject: [PATCH 07/17] lib.ExecContext: Clean up temp file in _put() _put() writes the content into a temporary file with tee and, when atomic is set, moves it to the target path with a final mv. The loop over the command list sets tmp_file to None after each command, on the assumption stated in the comment that the file has been moved at that point - which is only true for the last command. When a chown, chmod, or mv after the tee fails, the finally block finds tmp_file is None and leaves the temporary file behind. Reset tmp_file only after all commands have completed, so that the finally block erases the temporary file whenever a step fails. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ExecContext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/ExecContext.py b/src/python/jw/pkg/lib/ExecContext.py index 35685cc7..adef74bc 100644 --- a/src/python/jw/pkg/lib/ExecContext.py +++ b/src/python/jw/pkg/lib/ExecContext.py @@ -553,7 +553,7 @@ class ExecContext(Base): for cmd in cmds: log(DEBUG, f'{self.log_name}: Running {pretty_cmd(cmd.cmd, wd)}') ret = await __run(cmd.cmd, cmd_input = cmd.cmd_input) - tmp_file = None # Has been successfully moved at this point + tmp_file = None # All commands, including the final mv, succeeded return ret finally: if tmp_file is not None: -- 2.55.0 From 91c7d8436ef3e6657d7ed8646fa88758526e0d18 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:00:41 +0200 Subject: [PATCH 08/17] lib.ExecContext: Fix error message in _put() _put() catches a failure of the remote command sequence and logs "Failed to get from ", a phrase copied over from the get() path. This path, however, pushes content, so the message describes the wrong operation. Log a put-oriented message instead. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ExecContext.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/ExecContext.py b/src/python/jw/pkg/lib/ExecContext.py index adef74bc..55faf2c7 100644 --- a/src/python/jw/pkg/lib/ExecContext.py +++ b/src/python/jw/pkg/lib/ExecContext.py @@ -560,7 +560,7 @@ class ExecContext(Base): await self.erase(tmp_file) await self.close() except Exception as e: - msg = f'Failed to get {path} from {self.root} ({str(e)})' + msg = f'Failed to put content to {path} on {self.root} ({str(e)})' if throw: raise Exception(msg) log(ERR, msg) -- 2.55.0 From b78d922f0add37ef27a5817e9567b7cba1b8a53b Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:00:52 +0200 Subject: [PATCH 09/17] lib.ec.ssh.AsyncSSH: Actually hide password _connect_kwargs(hide_secrets = True) is used to log the connection parameters when a connection fails, without leaking the password. The filtered dictionary is built before the password is replaced with '', and the replacement is applied to the local kwargs dictionary afterwards, after the filtered copy has already been made. The dictionary that ends up in the log therefore still contains the real password. Hide the password before building the filtered dictionary. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py b/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py index a6ce7af5..cd7d53d6 100644 --- a/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py +++ b/src/python/jw/pkg/lib/ec/ssh/AsyncSSH.py @@ -72,10 +72,9 @@ class AsyncSSH(Base): } if self.__known_hosts is not _USE_DEFAULT_KNOWN_HOSTS: kwargs['known_hosts'] = self.__known_hosts - ret = {k: v for k, v in kwargs.items() if v is not None} if hide_secrets and 'password' in kwargs: kwargs['password'] = '' - return ret + return {k: v for k, v in kwargs.items() if v is not None} @property async def _conn(self) -> asyncssh.SSHClientConnection: -- 2.55.0 From 8f52639d14997929eeb4f9e1b9a1be80928531f1 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:01:04 +0200 Subject: [PATCH 10/17] lib.App: Restore caller's event loop in run() run() remembers the thread's current event loop before creating a new one only when the app does not own a loop yet. The finally block, however, always restores the remembered loop or unsets the loop when there was none. So when a caller passes its own event loop to the App constructor and has set it as the thread's current loop, run() unsets the thread's loop on the way out, although it never changed it. Remember the thread's current event loop unconditionally and restore it in the finally block; for a caller-provided loop that is a no-op. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index a0febcc7..858c7666 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -513,9 +513,8 @@ class App: # export return self.__parser def run(self, argv: list[str] | None = None) -> None: - previous_eloop: asyncio.AbstractEventLoop | None = None + previous_eloop = _get_current_event_loop() if self.__eloop is None: - previous_eloop = _get_current_event_loop() eloop = asyncio.new_event_loop() asyncio.set_event_loop(eloop) self.__eloop = eloop -- 2.55.0 From ebe187788a45eb095bb85a8286240ac2a9e231bf Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:01:14 +0200 Subject: [PATCH 11/17] lib.pm.dpkg: Pass context to run_sudo() _run() forwards the execution context to run_cmd() when sudo is not requested, but calls run_sudo(cmd) without the context in the sudo case. run_sudo() then falls back to a fresh local context, so sudoed dpkg and dpkg-query commands run on the local machine instead of on the given context, e.g. Distro._delete() on a remote host. Pass the context and non-interactive stdin to run_sudo() as well. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/pm/dpkg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/pm/dpkg.py b/src/python/jw/pkg/lib/pm/dpkg.py index a7b30813..8b2857e9 100644 --- a/src/python/jw/pkg/lib/pm/dpkg.py +++ b/src/python/jw/pkg/lib/pm/dpkg.py @@ -29,7 +29,7 @@ async def _run( cmd: list[str], sudo: bool = False, ec: ExecContext | None = None ) -> str: return ( - await run_sudo(cmd) + await run_sudo(cmd, ec = ec, cmd_input = InputMode.NonInteractive) if sudo else await run_cmd(cmd, ec = ec, cmd_input = InputMode.NonInteractive) ).stdout_str -- 2.55.0 From cc6c99f1658ddbcfa0f429090c378bc166335a4e Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:01:55 +0200 Subject: [PATCH 12/17] lib.ec.ssh.Paramiko: Pass port and password __client() connects using only the URI's hostname and username. The URI's port is ignored, so ssh://host:2222/... ends up connecting to port 22, and a password carried in the URI is never passed to paramiko, so URI-based password authentication cannot work. The Exec and AsyncSSH clients both honor the port and the password. Pass the port and the password to connect(). The port argument is omitted entirely when the URI carries no port, because getaddrinfo() would interpret a None port as service port 0; without the argument, paramiko falls back to its default of 22. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ec/ssh/Paramiko.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py index 84ac0d9e..0af7af48 100644 --- a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py +++ b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py @@ -32,10 +32,16 @@ class Paramiko(Base): hostname = self.hostname if hostname is None: raise Exception('Tried to run connect without target hostname') + kwargs: dict[str, Any] = { + 'hostname': hostname, + 'username': self.username, + 'password': self.password, + 'allow_agent': True, + } + if self.port is not None: + kwargs['port'] = self.port try: - ret.connect( - hostname = hostname, username = self.username, allow_agent = True - ) + ret.connect(**kwargs) except Exception as e: log(ERR, f'Failed to connect to {self.hostname} ({str(e)})') raise -- 2.55.0 From 28819d5f492bb29bda528086961619acf86272da Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:02:25 +0200 Subject: [PATCH 13/17] lib.ec.ssh.Paramiko: Close remote stdin _run_ssh() writes cmd_input to the remote stdin channel but never closes the write side. The channel stays open until the client process exits, so a remote command that reads stdin (cat, a login shell, ...) never sees EOF and blocks forever - including for cmd_input = None, which is supposed to mean non-interactive with stdin from /dev/null. Call shutdown_write() on the channel after writing the input, or immediately when there is none, so the remote command gets EOF. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ec/ssh/Paramiko.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py index 0af7af48..b85b20f4 100644 --- a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py +++ b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py @@ -94,5 +94,6 @@ class Paramiko(Base): raise if cmd_input is not None: stdin.write(cmd_input) + stdin.channel.shutdown_write() exit_status = stdout.channel.recv_exit_status() return Result(stdout.read(), stderr.read(), exit_status, cmd = cmd) -- 2.55.0 From d89afdc4a503767aeba3f4c25a202883b831e9ed Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:02:54 +0200 Subject: [PATCH 14/17] lib.ec.ssh.Paramiko: Avoid recv deadlock _run_ssh() waits for the remote process's exit status before reading stdout and stderr. When a command produces more output than the channel's flow-control window can hold, the server stops sending, the remote process blocks on its write and never exits, and recv_exit_status() blocks forever. Drain stdout and stderr to EOF first - the channels close when the process exits, so reading them also implies completion - and only then query the exit status. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ec/ssh/Paramiko.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py index b85b20f4..d1171d6f 100644 --- a/src/python/jw/pkg/lib/ec/ssh/Paramiko.py +++ b/src/python/jw/pkg/lib/ec/ssh/Paramiko.py @@ -95,5 +95,7 @@ class Paramiko(Base): if cmd_input is not None: stdin.write(cmd_input) stdin.channel.shutdown_write() + stdout_data = stdout.read() + stderr_data = stderr.read() exit_status = stdout.channel.recv_exit_status() - return Result(stdout.read(), stderr.read(), exit_status, cmd = cmd) + return Result(stdout_data, stderr_data, exit_status, cmd = cmd) -- 2.55.0 From 6c4ac7559693386c90a217a8757b0bcb6bde15af Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:03:03 +0200 Subject: [PATCH 15/17] lib.ec.ssh.Exec: Drop askpass newline __init_askpass() generates an askpass script whose echo statement embeds a newline inside the quoted password: echo -n "PASSWORD ". Bash happily spans the quote over the newline, and the script therefore prints the password followed by a trailing newline. run_askpass() returns that output verbatim, so the password passed on carries a newline and authentication fails. Write the script so it prints the password and nothing else. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ec/ssh/Exec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/ec/ssh/Exec.py b/src/python/jw/pkg/lib/ec/ssh/Exec.py index 4e169551..ac405346 100644 --- a/src/python/jw/pkg/lib/ec/ssh/Exec.py +++ b/src/python/jw/pkg/lib/ec/ssh/Exec.py @@ -39,7 +39,7 @@ class Exec(Base): ) os.chmod(f.name, 0o0700) self.__askpass = f.name - f.write(f'#!/bin/bash\n\necho -n "{self.password}\n"') + f.write(f'#!/bin/bash\n\necho -n "{self.password}"\n') f.close() for key, val in { 'SSH_ASKPASS': self.__askpass, -- 2.55.0 From 8aab4397970875be49d010e0dd93c2b3f69aa9e7 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sun, 16 Aug 2026 07:03:10 +0200 Subject: [PATCH 16/17] lib.TarIo: Fix missing paren in _read_filtered() _read_filtered() logs an error when reading the tar file fails, but the opening parenthesis of the error detail after the path is never closed. Close it. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/TarIo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/TarIo.py b/src/python/jw/pkg/lib/TarIo.py index e0aee4da..9853dace 100644 --- a/src/python/jw/pkg/lib/TarIo.py +++ b/src/python/jw/pkg/lib/TarIo.py @@ -51,7 +51,7 @@ class TarIo(CopyContext): try: blob = (await self.src.get(path)).stdout except Exception as e: - log(ERR, f'Failed to read tar file "{path}" ({str(e)}') + log(ERR, f'Failed to read tar file "{path}" ({str(e)})') raise return self._filter_tar_file(blob, path_filter, matched = matched) -- 2.55.0 From 670a46d0213380527c482f0be724cf6740cfb627 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 22 Aug 2026 11:27:19 +0200 Subject: [PATCH 17/17] lib.ExecApp: Add class Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/ExecApp.py | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/python/jw/pkg/lib/ExecApp.py diff --git a/src/python/jw/pkg/lib/ExecApp.py b/src/python/jw/pkg/lib/ExecApp.py new file mode 100644 index 00000000..992b2cea --- /dev/null +++ b/src/python/jw/pkg/lib/ExecApp.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import sys + +from typing import TYPE_CHECKING, Any, override + +from .App import App as Base + +if TYPE_CHECKING: + + from argparse import ArgumentParser + + from .ExecContext import ExecContext + +class ExecApp(Base): # export + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.__opt_interactive: bool | None = None + self.__opt_verbose: bool | None = None + self.__exec_context: ExecContext | None = None + + @override + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + if self.__exec_context is not None: + await self.__exec_context.close() + self.__exec_context = None + + @override + def _add_arguments(self, parser: ArgumentParser) -> None: + super()._add_arguments(parser) + parser.add_argument( + '--interactive', + choices = ['true', 'false', 'auto'], + default = 'true', + help = 'Wait for user input or try to proceed unattended', + ) + parser.add_argument( + '--verbose', + action = 'store_true', + default = False, + help = "Be verbose on stderr about what's being done on the distro level", + ) + parser.add_argument( + '--target', default = 'local', help = 'Run commands on this host' + ) + + @property + def interactive(self) -> bool: + if self.__opt_interactive is None: + match self.args.interactive: + case 'true': + self.__opt_interactive = True + case 'false': + self.__opt_interactive = False + case 'auto': + self.__opt_interactive = sys.stdin.isatty() + case _: + raise ValueError( + f'Unknown --interactive value: {self.args.interactive}' + ) + # Not logically possible to fail, but this keeps pyright happy + assert self.__opt_interactive is not None + return self.__opt_interactive + + @property + def verbose(self) -> bool: + if self.__opt_verbose is None: + self.__opt_verbose = self.args.verbose + # Not logically possible to fail, but this keeps pyright happy + assert self.__opt_verbose is not None + return self.__opt_verbose + + @property + def exec_context(self) -> ExecContext: + if self.__exec_context is None: + from .ExecContext import ExecContext + + self.__exec_context = ExecContext.create( + self.args.target, + interactive = self.interactive, + verbose_default = self.verbose, + ) + return self.__exec_context -- 2.55.0