dotfm

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

dotfm.py (14033B)


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