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