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