dotfm

My dotfile manager
git clone git://src.gearsix.net/dotfm
Log | Files | Refs | Atom | README | LICENSE

dotfm.py (13866B)


      1 #!/usr/bin/env python3
      2 
      3 #=========================
      4 # dotfm - dotfile manager
      5 #=========================
      6 # authors: gearsix
      7 # created: 2020-01-15
      8 # updated: 2021-09-06
      9 
     10 #---------
     11 # IMPORTS
     12 #---------
     13 # std
     14 import sys
     15 import os
     16 import csv
     17 import logging
     18 import argparse
     19 
     20 #---------
     21 # GLOBALS
     22 #---------
     23 NAME = os.path.basename(__file__)
     24 HOME = os.getenv('HOME')
     25 ARGS = []
     26 
     27 EDITOR = os.getenv('EDITOR') or 'nano'
     28 
     29 VERSION = 'v2.5.0'
     30 
     31 INSTALLED = []
     32 
     33 INSTALLED_FILE = ''
     34 if sys.platform == 'linux' or sys.platform == 'linux2':
     35 	INSTALLED_FILE = '{}/.local/share/dotfm/installed.csv'.format(HOME)
     36 elif sys.platform == 'darwin':
     37 	INSTALLED_FILE = '{}/Library/Application Support/dotfm/installed.csv'.format(HOME)
     38 else:
     39 	print('warning: unsupported system, things might break')
     40 
     41 KNOWN = [ # dotfiles that dotfm knows by default
     42     # install location, aliases...
     43     [INSTALLED_FILE, 'dotfm'],
     44     ['{}/.bashrc'.format(HOME), '.bashrc', 'bashrc'],
     45     ['{}/.bash_profile'.format(HOME), '.bash_profile', 'bash_profile'],
     46     ['{}/.profile'.format(HOME), '.profile', 'profile'],
     47     ['{}/.zshrc'.format(HOME), '.zshrc', 'zshrc'],
     48     ['{}/.zprofile'.format(HOME), '.zprofile', 'zprofile'],
     49     ['{}/.zshenv'.format(HOME), '.zshenv', 'zshenv'],
     50     ['{}/.ssh/config'.format(HOME), 'ssh_config'],
     51     ['{}/.vimrc'.format(HOME), '.vimrc', 'vimrc'],
     52     ['{}/.config/nvim/init.vim'.format(HOME), 'init.vim', 'nvimrc'],
     53     ['{}/.gitconfig'.format(HOME), '.gitconfig', 'gitconfig'],
     54     ['{}/.gitmessage'.format(HOME), '.gitmessage', 'gitmessage'],
     55     ['{}/.gitignore'.format(HOME), '.gitignore', 'gitignore'],
     56     ['{}/.gemrc'.format(HOME), '.gemrc', 'gemrc'],
     57     ['{}/.tmux.conf'.format(HOME), '.tmux.conf', 'tmux.conf'],
     58     ['{}/.config/user-dirs.dirs'.format(HOME), 'user-dirs.dirs', 'xdg-user-dirs'],
     59     ['{}/.xinitrc'.format(HOME), '.xinitrc', 'xinitrc'],
     60     ['{}/.config/rc.conf'.format(HOME), 'rc.conf', 'ranger.conf', 'ranger.cfg'],
     61     ['{}/.config/neofetch/config'.format(HOME), 'config', 'neofetch.conf', 'neofetch.cfg'],
     62     ['{}/.config/sway/config'.format(HOME), 'config', 'sway.cfg', 'sway.conf'],
     63     ['{}/.config/awesome/rc.lua'.format(HOME), 'rc.lua', 'awesomerc'],
     64     ['{}/.config/i3/config'.format(HOME), 'config', 'i3.conf', 'i3.cfg', 'i3'],
     65     ['{}/.emacs'.format(HOME), '.emacs', 'emacs'],
     66     ['{}/.sfeed/sfeedrc'.format(HOME), '.sfeedrc', 'sfeedrc'],
     67     ['{}/.config/txtnish/config'.format(HOME), 'txtnish', 'txtnish_config'],
     68     ['{}/.config/micro/bindings.json'.format(HOME), 'bindings.json', 'micro.bindings', 'micro.kbd'],
     69     ['{}/.config/micro/settings.json'.format(HOME), 'settings.json', 'micro.settings', 'micro.cfg'],
     70 ]
     71 
     72 #-----------
     73 # FUNCTIONS
     74 #-----------
     75 # utilities
     76 def ask(message):
     77     return input('dotfm | {} '.format(message))
     78 
     79 def log(message):
     80     print('dotfm | {}'.format(message))
     81     
     82 def debug(message):
     83     if ARGS.debug == True:
     84         log(message)
     85         
     86 def info(message):
     87     if ARGS.quiet == False:
     88         log(message)
     89 
     90 def warn(message):
     91     ask('{}, press key to continue'.format(message))
     92 
     93 # main
     94 def parseargs():
     95     valid_commands = ['install', 'in', 'update', 'up', 'link', 'ln', 'remove', 'rm', 'edit', 'ed', 'list', 'ls']
     96     parser = argparse.ArgumentParser(description='a simple tool to help you manage your dotfile symlinks.')
     97     # OPTIONS
     98     parser.add_argument('-s', '--skip', action='store_true',
     99             help='skip any user prompts and use default values where possible')
    100     parser.add_argument('-d', '--debug', action='store_true',
    101             help='display debug logs')
    102     parser.add_argument('-v', '--version', action='version',
    103             version='%(prog)s {}'.format(VERSION))
    104     parser.add_argument('-q', '--quiet', action='store_true',
    105             help='mute dotfm info logs')
    106     # POSITIONAL
    107     parser.add_argument('cmd', metavar='COMMAND', choices=valid_commands,
    108             help='the dotfm COMMAND to execute: {}'.format(valid_commands))
    109     parser.add_argument('dotfile', metavar='DOTFILE', nargs=argparse.REMAINDER,
    110             help='the target dotfile to execute COMMAND on')
    111     return parser.parse_args()
    112 
    113 def writeinstalled():
    114     with open(INSTALLED_FILE, "w") as dotfm_csv_file:
    115         dotfm_csv_writer = csv.writer(dotfm_csv_file, lineterminator='\n')
    116         for dfl in INSTALLED:
    117             dotfm_csv_writer.writerow(dfl)
    118         dotfm_csv_file.close()
    119 
    120 def isdotfile(dotfile_list, query):
    121     query = os.path.basename(query)
    122     debug('checking for {}'.format(query))
    123     found = -1
    124     for d, dfl in enumerate(dotfile_list):
    125         if query == os.path.basename(dfl[0]) or query in dfl:
    126             found = d
    127         if found != -1:
    128             debug('dotfile {} matches known dotfile alias for {}'.format(query, dfl[0]))
    129             break
    130     return found
    131 
    132 def clearduplicates(dotfile_list, id_index=0):
    133     for i, d in enumerate(dotfile_list):
    134         if len(d) == 0:
    135             continue
    136         for j, dd in enumerate(dotfile_list):
    137             if len(dd) == 0:
    138                 continue
    139             if j > i and dd[id_index] == d[id_index]:
    140                 dotfile_list.remove(d)
    141                 break
    142 
    143 # main/init
    144 def init():
    145     debug('init...')
    146     if not os.path.exists(INSTALLED_FILE):
    147         debug('{} not found'.format(INSTALLED_FILE))
    148         init_createcsv(INSTALLED_FILE)
    149     init_loadcsv(INSTALLED_FILE)
    150     clearduplicates(INSTALLED)
    151     debug('loaded dotfile list: {}'.format(INSTALLED))
    152 
    153 def init_createcsv(default_location):
    154     location = default_location
    155     if ARGS.skip == False:
    156         info('default dotfm csv filepath: "{}"'.format(default_location))
    157         location = ask('dotfm csv filepath (enter for default)? ')
    158         if len(location) == 0:
    159             location = default_location
    160         if os.path.exists(location):
    161             debug('{} already exists'.format(location))
    162             on = ask('[o]verwrite or [u]se {}? '.format(location))
    163             if len(on) > 0:
    164                 if on[0] == 'o': # create file at location & write KNOWN[0] to it
    165                     warn('overwriting {}, all existing data in this file will be lost'.format(location))
    166                     os.makedirs(os.path.dirname(location), exist_ok=True)
    167                     dotfm_csv = open(location, "w")
    168                     for i, dfl in enumerate(KNOWN[0]):
    169                         dotfm_csv.write(dfl if i == 0 else ',{}'.format(dfl))
    170                     dotfm_csv.write('\n')
    171                     dotfm_csv.close()
    172                 elif on[0] == 'u':
    173                     debug('using pre-existing csv {}'.format(location))
    174                     sys.exit()
    175 
    176     # create default_location symlink
    177     if os.path.abspath(location) != os.path.abspath(default_location):
    178         debug('creating dotfm csv file symlink')
    179         os.makedirs(os.path.dirname(default_location), exist_ok=True)
    180         os.system('ln -isv', os.path.abspath(location), default_location)
    181     else:
    182         os.makedirs(os.path.dirname(location), exist_ok=True)
    183         f = open(location, "w")
    184         f.close()
    185 
    186 def init_loadcsv(location):
    187     dotfm_csv = open(location, "r")
    188     dotfm_csv_reader = csv.reader(dotfm_csv)
    189     for dfl in dotfm_csv_reader:
    190         INSTALLED.append(dfl)
    191     dotfm_csv.close()
    192 
    193 # main/install
    194 def install(dotfile):
    195     info('installing {}...'.format(dotfile))
    196     known = isdotfile(KNOWN, dotfile)
    197     location = install_getlocation(known)
    198     aliases = install_getaliases(known)
    199     if not os.path.exists(os.path.dirname(location)):
    200         os.makedirs(os.path.dirname(location), exist_ok=True)
    201     if dotfile != location:
    202         if os.path.lexists(location):
    203             install_oca(dotfile, location)
    204         os.system('ln -vs {} {}'.format(dotfile, location))
    205     debug('appending to {} installed...'.format(location))
    206     aliases.insert(0, location)
    207     INSTALLED.append(aliases)
    208     clearduplicates(INSTALLED)
    209     info('success - you might need to re-open the terminal to see changes take effect')
    210 
    211 def install_getlocation(known_index, msg='install filepath?'):
    212     default = ''
    213     if known_index != -1:
    214         default = KNOWN[known_index][0]
    215         info('default install filepath is "{}"'.format(default))
    216         msg = 'install filepath (enter for default):'.format(default)
    217     if len(default) > 0 and ARGS.skip == True:
    218         return default
    219     location = ''
    220     while location == '':
    221         location = ask(msg)
    222         if len(location) == 0 and len(default) > 0:
    223             return default
    224         elif location.find('~') != -1:
    225             return location.replace('~', HOME)
    226         else:
    227             debug('invalid filepath "{}"'.format(location))
    228             location = ''
    229 
    230 def install_getaliases(known_index):
    231     default = ''
    232     if known_index != -1:
    233         default = KNOWN[known_index][1:]
    234         info('default aliases are "{}"'.format(' '.join(default)))
    235     if len(default) > 0 and ARGS.skip == True:
    236         return default
    237     aliases = ''
    238     while aliases == '':
    239         aliases = ask('dotfile aliases (enter for default): '.format(
    240             ('defaults', default) if len(default) > 0 else ''))
    241         if len(aliases) > 0:
    242             return aliases.split(' ')
    243         elif len(default) > 0:
    244             return default
    245 
    246 def install_oca(dotfile, location):
    247     oca = ''
    248     while oca == '':
    249         oca = ask('{} already exists, [o]verwrite/[c]ompare/[a]bort? '.format(location))
    250         if len(oca) > 0:
    251             if oca[0] == 'o': # overwrite
    252                 os.remove(location)
    253             elif oca[0] == 'c': # compare
    254                 debug('comparing {} to {}'.format(dotfile, location))
    255                 os.system('diff -bys {} {}'.format(dotfile, location))
    256                 oca = ''
    257             elif oca[0] == 'a': # abort
    258                 debug('aborting install')
    259                 sys.exit()
    260             else:
    261                 oca = ''
    262     return oca
    263 
    264 # main/update
    265 def update(alias, location):
    266     debug('updating {} -> {}'.format(alias, location))
    267     known = isdotfile(INSTALLED, alias)
    268     if known != -1:
    269         os.system('ln -isv {} {}'.format(location, INSTALLED[known][0]))
    270     else:
    271         warn('{} is unrecognised, installing'.format(dotfile))
    272         install(location)
    273 
    274 # main/link
    275 def link(dotfile):
    276     dotfm_dir='~/.dotfiles/'
    277 
    278     if 'DFMDIR' in os.environ:
    279         dotfm_dir = os.environ('DFMDIR')
    280     else:
    281         log('default dotfm dir: "{}"'.format(dotfm_dir))
    282         d = ask('link to (enter for default)? '.format(dotfm_dir))
    283         if os.path.exists(d):
    284             dotfm_dir=d
    285 
    286     dotfm_dir = dotfm_dir.replace('~', HOME)
    287     os.makedirs(dotfm_dir, exist_ok=True)
    288 
    289     target=os.path.join(dotfm_dir, os.path.basename(dotfile))
    290 
    291     debug('linking {} -> {}'.format(dotfile, target))
    292     if not os.path.exists(dotfile):
    293         answer = ask('"{}" does not exist, create [y/n]?'.format(dotfile))
    294         debug(answer)
    295         if answer[0] == 'y':
    296             f = open(dotfile, 'w')
    297             f.close()
    298         else:
    299             return
    300     if os.path.exists(target):
    301         answer = install_oca(dotfile, target)
    302     os.link(dotfile, target)
    303 
    304 # main/remove
    305 def remove(dotfile):
    306     debug('removing {}'.format(dotfile))
    307 
    308     index = isdotfile(INSTALLED, dotfile)
    309     if index == -1:
    310         warn('could not find dotfile "{}"'.format(dotfile))
    311         return
    312     dotfile = os.path.abspath(INSTALLED[index][0])
    313     confirm = ''
    314     while confirm == '':
    315         confirm = ask('remove "{}", are you sure [y/n]?'.format(dotfile))
    316     try:
    317         os.remove(dotfile)
    318     except OSError as err:
    319         warn('cannot remove "{}"...\n{}'.format(dotfile, err))
    320     del INSTALLED[index]
    321     writeinstalled()
    322 
    323 # main/edit
    324 def edit(dotfile):
    325     debug('editing {}'.format(dotfile))
    326     index = isdotfile(INSTALLED, dotfile)
    327     if index == -1:
    328         if edit_promptinstall(dotfile) == 'y':
    329             index = isdotfile(INSTALLED, dotfile)
    330         else:
    331             return
    332     target = INSTALLED[index][0]
    333     os.system('{} {}'.format(EDITOR, target))
    334 
    335 def edit_promptinstall(dotfile):
    336     yn = '-'
    337     while yn[0] != 'y' and yn[0] != 'n':
    338         yn = ask('could not find installed dotfile matching "{}", install [y/n]? '.format(dotfile))
    339         if len(yn) == 0:
    340             yn = '-'
    341         if yn[0] == 'y':
    342             install(install_getlocation(-1, msg='input source path:'))
    343     return yn[0]
    344 
    345 # main/list
    346 def list(dotfiles):
    347     debug('listing dotfiles: {}'.format(dotfiles))
    348     if len(dotfiles) == 0:
    349         os.system('cat "{}" | sed "s/,/\t/g"'.format(INSTALLED_FILE))
    350     else:
    351         data = ''
    352         for d in dotfiles:
    353             for i in INSTALLED:
    354                 if d in i:
    355                     data += '{},'.format(i[0])
    356                     for alias in i[1:]:
    357                         data += ',{}'.format(alias)
    358                     data += '\n'
    359         os.system('printf "LOCATION,ALIASES...\n{}" | column -t -s ,'.format(data))
    360 
    361 #------
    362 # MAIN
    363 #------
    364 if __name__ == '__main__':
    365     ARGS = parseargs()
    366     if ARGS.debug == True:
    367         debug('printing debug logs')
    368         debug('args = {}'.format(ARGS))
    369     if ARGS.quiet == True:
    370         debug('muting info logs')
    371 
    372     init()
    373     if ARGS.cmd == 'install' or ARGS.cmd == 'in':
    374         for d in ARGS.dotfile:
    375             install(os.path.abspath(d))
    376     elif ARGS.cmd == 'update' or ARGS.cmd == 'up':
    377         if len(ARGS.dotfile) < 2:
    378             debug('invalid number of arguments')
    379             info('usage: "dotfm update DOTFILE LOCATION"')
    380             sys.exit()
    381         update(ARGS.dotfile[0], ARGS.dotfile[1])
    382     elif ARGS.cmd == 'link' or ARGS.cmd == 'ln':
    383         for d in ARGS.dotfile:
    384             link(d)
    385     elif ARGS.cmd == 'remove' or ARGS.cmd == 'rm':
    386         for d in ARGS.dotfile:
    387             remove(d)
    388     elif ARGS.cmd == 'edit' or ARGS.cmd == 'ed':
    389         for d in ARGS.dotfile:
    390             edit(d)
    391     elif ARGS.cmd == 'list' or ARGS.cmd == 'ls':
    392         list(ARGS.dotfile)
    393     writeinstalled()
    394