1
0
Fork 0
You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

187 lines
5.9 KiB
Python

#!/bin/python3
import pandoc
import pandoc.types as t
import sys
# 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
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: Any[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]
# We now check the separate lines and possibly tweak them (e.g. by removing sudo-s)
lines = s[1].split('\n')
directory = None # Don't cd by default
user = 'root' # Connect as root by default (it does not really matter, since the commands are run with sudo's)
result = []
for line in lines:
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=5)
assert parts[3] == '-H', "Insecure sudo?"
user = parts[2]
directory = f'~{user}' # This actually works with SSH. That is nice, since we don't need to know the mapping of
cmd = parts[4]
elif line.startswith('sudo'):
parts = line.split(maxsplit=2)
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.
user = 'root' # Reset to default
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'
# 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), skip(n)?")
if answer.startswith(('e', 'E')):
import tempfile
with tempfile.NamedTemporaryFile() as tmpfile:
# Fill the file with data
for cmd in hunk:
tmpfile.file.write(f"{cmd.user}\t{cmd.directory}\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
new_hunk = []
for line in tmpfile.file.readlines():
parts = line.split('\t', maxsplit=3)
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 []
def run_command(cmd: Command):
# Generate and run the SSH command.
global host
local_command = ['ssh', host]
if cmd.user is not None:
local_command.extend(['-l', cmd.user])
if cmd.directory is not None:
cmd.command += f'cd {cmd.directory}; {cmd.command}'
local_command.append(cmd.command)
# Run
import subprocess
process = subprocess.run(local_command)
if process.returncode != 0:
# TODO: Print in color (red?)
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(open(fn))
snippets = extract_snippets(pd)
for snip in snippets:
cmds = parse_snippet(snip)
cmds = user_action(cmds)
for cmd in cmds:
run_command(cmd)
if __name__ == '__main__':
main()