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