#!/bin/python3 import pandoc import pandoc.types as t import sys from dataclasses import dataclass from typing import * # There are three parts: # - Parse a given markdown and extract the commands # - Also: keep track of the groups / ... # - Let the user decide, what to run / edit / split / skip # - Run the commands remotely # Usage: $0 remote.host.name md1 ... host: str directory: str = None def main(): if len(sys.argv) < 3: print(f"Usage: {sys.argv[0]} hostname file1.md ...") exit(1) # Here I would use Haskell's pointfree functions, so that host is implicitly passed... # Never mind, it will be a global variable NOTE THIS!!! global host host = sys.argv[1] for file in sys.argv[2:]: process_file(file) @dataclass class Command: user: str directory: Union[str, None] command: str context: str def stringify(block) -> str: """ Take an object of some of pandoc.types and make the best of it. """ # The pandoc library has a fancy function for that... return pandoc.write(block) def extract_snippets(pd: t.Pandoc) -> Sequence[Tuple[str, str]]: """ This extracts snippets from the code. The problem is, that we want context with it, so we return a list of tuples (previous_block_stringified, code_snippet). """ result = [] last_block = "" for block in pd[1]: if isinstance(block, t.CodeBlock): result.append((last_block, block[1])) last_block = stringify(block) return result def parse_snippet(s: Tuple[str, str]) -> Sequence[Command]: # Will return always a list, even if it has only one element. # This is where we handle blank lines, comments, cd's and sudo's, so that the Commands in the output are "runnable" # The original context should be mentioned for all hunks context = s[0] snippet = s[1] # Fix line-breaks # FIXME: nonsystematic and ad hoc snippet = snippet.replace('\\\n', '') # We now check the separate lines and possibly tweak them (e.g. by removing sudo-s) lines = snippet.split('\n') global directory result = [] for line in lines: user = None # This allows (in conjunction with the right order of arguments to 'ssh') to specify the default user in the hostname (as 'user@host.name') line = line.strip() if line.startswith('cd'): parts = line.split() assert len(parts) == 2 directory = parts[1] continue elif line.startswith('sudo -u'): parts = line.split(maxsplit=4) assert parts[3] == '-H', "Insecure sudo?" user = parts[2] cmd = parts[4] elif line.startswith('sudo'): parts = line.split(maxsplit=1) assert not parts[1].startswith('-'), "Weird sudo" user = 'root' cmd = parts[1] elif line == '': # Only add it to context for reference context = f'{context}\n' continue else: # No sudo or cd, just a command. cmd = line result.append(Command(user=user,directory=directory,command=cmd,context=context)) # Let's have the previously processed commands in the context context = f"{context}\nCMD: {user}:{directory if directory else '??'} $ {cmd}" return result # I miss the right feature of Nim lang... def user_action(hunk: Sequence[Command], context=None) -> Sequence[Command]: # Maybe modify the commands to run. # This should have a simple UI: show the hunk with context (ideally in grey), ask what to do, return the wanted command # Actions: yes(y): accept input; no(n): skip this command; edit(e): launch $EDITOR or vim on the hunk; quit(q): terminate; split(s): split hunks to individual lines and ask for each one. # FIXME: We don't implement splitting yet, because it's simple to comment / delete in the editor # FIXME: We only show given context and don't care for the command's one; that's wrong. def stringify_hunk(hunk): result = '' for cmd in hunk: global host result += f'SSH: {cmd.user}@{host} {cmd.directory} $ {cmd.command}\n' return result # TODO: Print context in color (gray?) print('-------------------------------------------') print(context) print() print(stringify_hunk(hunk)) # TODO: Print prompt in color (green?) answer = input("What to do: edit(e), run(y, default), skip(n)?") if answer is None or answer == '': # Default is run, for convenience answer = 'Y' if answer.startswith(('e', 'E')): import tempfile with tempfile.NamedTemporaryFile(mode='r+') as tmpfile: # Fill the file with data for cmd in hunk: tmpfile.file.write(f"{cmd.user if cmd.user else ''}\t{cmd.directory if cmd.directory else ''}\t{cmd.command}\n") tmpfile.file.flush() # Should not be needed, but better safe than sorry # Let the user edit the file import os editor = os.getenv('EDITOR') if editor is None: # Fallback to vim editor = 'vim' import subprocess subprocess.run([editor, tmpfile.name]) # Read the file back tmpfile.file.seek(0) new_hunk = [] for line in tmpfile.file.readlines(): if line == '': continue parts = line.split('\t', maxsplit=2) # Change user to None if it is not specified parts[0] = parts[0].strip() if parts[0].strip() != '' else None # Change directory to None if it is not specified parts[1] = parts[1].strip() if parts[1].strip() != '' else None new_hunk.append(Command(user=parts[0],directory=parts[1],command=parts[2],context=None)) # Python has no goto for restarting, but we can recurse return user_action(new_hunk, context) elif answer.startswith(('y', 'Y')): return hunk elif answer.startswith(('n', 'N')): return [] else: # Bad choice or error. Notify user and try again print("Bad choice, or maybe bug.") # No goto, use recursion return user_action(hunk, context) def run_command(cmd: Command): # Generate and run the SSH command. global host local_command = ['ssh'] remote_command = cmd.command if cmd.user is not None: local_command.extend(['-l', cmd.user]) local_command.append(host) # SSH seems to honor the first specified parameter. This allows to have username as a part of the hostname, yet still be able to honor the sudo-s in the sinppets. if cmd.directory is not None: remote_command = f'cd {cmd.directory}; {remote_command}' local_command.append(remote_command) # Run import subprocess process = subprocess.run(local_command) if process.returncode != 0: # TODO: Print in color (red?) # FIXME: This kind-of implements 'set -e', but doesn't allow to skip commands in the same hunk answer = input(f"Command {process.args} failed with return code {process.returncode}. Continue? ") if not answer.startswith(('Y', 'y', 'a', 'A')): # We are Czech, so "ano" is a valid answer :-) print("Bailing out") sys.exit(1) def process_file(fn: str): """ To be called upon each file on the command line. This function should perform all the steps needed in order to remotely run a parsed markdown, id est the three parts above. """ pd = pandoc.read(file=fn) snippets = extract_snippets(pd) for snip in snippets: cmds = parse_snippet(snip) cmds = user_action(cmds, context=cmds[0].context) for cmd in cmds: run_command(cmd) if __name__ == '__main__': main()