Helps centralize dotfiles to a single repository
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial creation

nnuuvv 3a4486bc

+502
+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v4 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "27.1.2" 18 + gleam-version: "1.12.0" 19 + rebar3-version: "3" 20 + # elixir-version: "1" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+39
README.md
··· 1 + # dotfile_helper 2 + 3 + ### Features 4 + 5 + - new | set up a fresh repo in ~/dotfiles 6 + - init | initialize symlinks based on existing ~/dotfiles repo 7 + - init <link> | like init, but clones the supplied repo to ~/dotfiles first 8 + - add <path> | add an existing dot file to the ~/dotfiles repo and set up the symlink 9 + - add submodule <link> <path> | like add, but it will be added as a submodule 10 + 11 + TODO: builds and releases 12 + 13 + --- 14 + #### Usage examples 15 + 16 + Initial setup 17 + ```sh 18 + gleam run new 19 + ``` 20 + 21 + Adding a local config 22 + ```sh 23 + gleam run add ~/.config/wezterm 24 + ``` 25 + 26 + Adding a config that is already tracked elsewhere 27 + ```sh 28 + gleam run add submodule git@git.nuv.sh:nuv/nvim ~/.config/nvim 29 + ``` 30 + The link `git@git.nuv.sh:nuv/nvim` and the path `~/.config/nvim` are directly passed to 31 + `git submodule add <link> <path>` running in `~/dotfiles` 32 + 33 + 34 + Setting up a new device with an existing dotfiles repo 35 + ```sh 36 + gleam run init git@git.nuv.sh:nuv/dotfiles 37 + ``` 38 + 39 +
+25
gleam.toml
··· 1 + name = "dotfiles" 2 + version = "1.0.0" 3 + 4 + # Fill out these fields if you intend to generate HTML documentation or publish 5 + # your project to the Hex package manager. 6 + # 7 + # description = "" 8 + # licences = ["Apache-2.0"] 9 + # repository = { type = "github", user = "", repo = "" } 10 + # links = [{ title = "Website", href = "" }] 11 + # 12 + # For a full reference of all the available options, you can have a look at 13 + # https://gleam.run/writing-gleam/gleam-toml/. 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 + simplifile = ">= 2.3.0 and < 3.0.0" 18 + filepath = ">= 1.1.2 and < 2.0.0" 19 + shellout = ">= 1.7.0 and < 2.0.0" 20 + argv = ">= 1.0.2 and < 2.0.0" 21 + envoy = ">= 1.0.2 and < 2.0.0" 22 + gleam_json = ">= 3.0.2 and < 4.0.0" 23 + 24 + [dev-dependencies] 25 + gleeunit = ">= 1.0.0 and < 2.0.0"
+23
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 6 + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 7 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 8 + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 9 + { name = "gleam_stdlib", version = "0.64.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "EA2E13FC4E65750643E078487D5EF360BEBCA5EBBBA12042FB589C19F53E35C0" }, 10 + { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, 11 + { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" }, 12 + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 13 + ] 14 + 15 + [requirements] 16 + argv = { version = ">= 1.0.2 and < 2.0.0" } 17 + envoy = { version = ">= 1.0.2 and < 2.0.0" } 18 + filepath = { version = ">= 1.1.2 and < 2.0.0" } 19 + gleam_json = { version = ">= 3.0.2 and < 4.0.0" } 20 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 21 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 22 + shellout = { version = ">= 1.7.0 and < 2.0.0" } 23 + simplifile = { version = ">= 2.3.0 and < 3.0.0" }
+375
src/dotfiles.gleam
··· 1 + import argv 2 + import envoy 3 + import filepath 4 + import gleam/dynamic/decode 5 + import gleam/io 6 + import gleam/json 7 + import gleam/list 8 + import gleam/result 9 + import gleam/string 10 + import shellout 11 + import simplifile 12 + 13 + /// location, relative to $HOME, of the dotfiles repo 14 + const dotfiles = "dotfiles" 15 + 16 + type InternalError { 17 + FailedToCopy(path: String, error: simplifile.FileError) 18 + FailedToRead(path: String, error: simplifile.FileError) 19 + FailedToWrite(path: String, error: simplifile.FileError) 20 + FileNotInHomeDirectory(file: String) 21 + FailedToCreateSymlink(#(Int, String)) 22 + FailedToDecode(json.DecodeError) 23 + FailedToCloneRepo(#(Int, String)) 24 + FailedToInitRepo(#(Int, String)) 25 + FailedToCreateDir(path: String, error: simplifile.FileError) 26 + } 27 + 28 + fn describe_error(error: InternalError) { 29 + case error { 30 + FailedToCloneRepo(_) -> string.inspect(error) 31 + FailedToCopy(path, inner) -> 32 + string.inspect(error) 33 + <> " " 34 + <> path 35 + <> " " 36 + <> simplifile.describe_error(inner) 37 + FailedToCreateSymlink(_) -> string.inspect(error) 38 + FailedToDecode(_) -> string.inspect(error) 39 + FailedToRead(path, inner) -> 40 + string.inspect(error) 41 + <> " " 42 + <> path 43 + <> " " 44 + <> simplifile.describe_error(inner) 45 + FailedToWrite(path, inner) -> 46 + string.inspect(error) 47 + <> " " 48 + <> path 49 + <> " " 50 + <> simplifile.describe_error(inner) 51 + FileNotInHomeDirectory(_) -> string.inspect(error) 52 + FailedToCreateDir(path, inner) -> 53 + string.inspect(error) 54 + <> " " 55 + <> path 56 + <> " " 57 + <> simplifile.describe_error(inner) 58 + FailedToInitRepo(_) -> string.inspect(error) 59 + } 60 + } 61 + 62 + pub fn main() -> Nil { 63 + let program_args = argv.load().arguments 64 + let assert Ok(home_dir) = envoy.get("HOME") as "$HOME not defined" 65 + 66 + case program_args { 67 + ["help"] -> show_help() 68 + ["new"] -> { 69 + let _ = 70 + setup_new(home_dir) 71 + |> result.map_error(describe_error) 72 + |> result.map_error(io.println_error) 73 + 74 + Nil 75 + } 76 + 77 + ["init", link] -> { 78 + let _ = 79 + init_from_link(link, home_dir) 80 + |> result.map_error(describe_error) 81 + |> result.map_error(io.println_error) 82 + 83 + Nil 84 + } 85 + ["init"] -> { 86 + let _ = 87 + update_symlinks(home_dir) 88 + |> result.map_error(describe_error) 89 + |> result.map_error(io.println_error) 90 + 91 + Nil 92 + } 93 + ["add", "submodule", link, path] -> { 94 + let _ = 95 + add_submodule(home_dir, link, path) 96 + |> result.map_error(describe_error) 97 + |> result.map_error(io.println_error) 98 + Nil 99 + } 100 + ["add", ..rest] -> { 101 + let _ = 102 + add_many(home_dir, rest) 103 + |> result.map_error(describe_error) 104 + |> result.map_error(io.println_error) 105 + Nil 106 + } 107 + _ -> show_help() 108 + } 109 + } 110 + 111 + fn setup_new(home_dir: String) { 112 + let path = filepath.join(home_dir, dotfiles) 113 + use _ <- result.try( 114 + simplifile.create_directory(path) 115 + |> result.map_error(FailedToCreateDir(path, _)), 116 + ) 117 + 118 + shellout.command(run: "git", with: ["init"], in: path, opt: []) 119 + |> result.map_error(FailedToInitRepo) 120 + } 121 + 122 + /// git clone's the provided link and then runs normal setup 123 + /// 124 + fn init_from_link(link: String, home: String) { 125 + use _ <- result.try(clone_repo(link, home)) 126 + update_symlinks(home) 127 + } 128 + 129 + fn clone_repo(link: String, home: String) { 130 + shellout.command( 131 + run: "git", 132 + with: ["clone", "--recurse-submodules", link, filepath.join(home, dotfiles)], 133 + in: ".", 134 + opt: [], 135 + ) 136 + |> result.map_error(FailedToCloneRepo) 137 + } 138 + 139 + fn add_submodule(home: String, link: String, config_path: String) { 140 + use spec <- result.try(spec_from_config_path(config_path)) 141 + 142 + use _ <- result.try( 143 + shellout.command( 144 + run: "git", 145 + with: ["submodule", "add", link, filepath.join(home, spec.dotfiles_path)], 146 + in: filepath.join(home, dotfiles), 147 + opt: [], 148 + ) 149 + |> result.map_error(FailedToCloneRepo), 150 + ) 151 + 152 + use _ <- result.try(make_symlink_from_spec(spec, home)) 153 + persist_spec(spec, home) 154 + } 155 + 156 + fn show_help() { 157 + io.println( 158 + " 159 + help -> show this 160 + new -> set up a fresh ~/dotfiles repo 161 + init -> initial setup of symlinks from ~/dotfiles to actual file locations 162 + init <link> -> like init, but clones the specified repo to ~/dotfiles as well 163 + add <path> -> add new config directory or file 164 + add submodule <link> <path> -> where <path> is the target path i.e. `~/.config/nvim` 165 + ", 166 + ) 167 + } 168 + 169 + /// add a list of new configs 170 + /// 171 + fn add_many(home: String, configs: List(String)) { 172 + configs 173 + |> list.map(spec_from_config_path) 174 + |> list.map(result.try(_, move_config_to_dotfiles(_, home))) 175 + |> list.map(result.try(_, make_symlink_from_spec(_, home))) 176 + |> list.map(result.map_error(_, describe_error)) 177 + |> list.map(result.map_error(_, io.println_error)) 178 + |> result.values() 179 + |> persist_specs(home) 180 + } 181 + 182 + /// Moves the `target_path` to `dotfiles_path` using simplifile.rename 183 + /// Returns the original spec 184 + /// 185 + fn move_config_to_dotfiles(spec: Spec, home) { 186 + simplifile.rename( 187 + filepath.join(home, spec.target_path), 188 + filepath.join(home, spec.dotfiles_path), 189 + ) 190 + |> result.map_error(FailedToCopy(filepath.join(home, spec.dotfiles_path), _)) 191 + |> result.replace(spec) 192 + } 193 + 194 + /// loads specs from spec.json and tries to create the symlinks based on it 195 + /// 196 + fn update_symlinks(home home: String) -> Result(List(String), InternalError) { 197 + use specs <- result.try( 198 + spec_path(home) 199 + |> load_specs, 200 + ) 201 + 202 + specs 203 + |> list.map(make_symlink_from_spec(_, home)) 204 + |> list.map(result.map(_, string.inspect)) 205 + |> result.all() 206 + } 207 + 208 + // path manipulation ------------------------------------------------------------ 209 + 210 + fn drop_home(from) { 211 + from 212 + |> filepath.split() 213 + // drop `/`, `home` and `<user>` 214 + |> list.drop(3) 215 + |> list.fold("", filepath.join) 216 + } 217 + 218 + fn to_dotfiles_path(path) { 219 + string.split(path, on: "/") 220 + |> list.map(string.replace(_, ".", "dot_")) 221 + |> string.join("/") 222 + |> filepath.join(dotfiles, _) 223 + } 224 + 225 + fn to_original_path(path) { 226 + path 227 + |> string.replace(dotfiles <> "/", "") 228 + |> string.split(on: "/") 229 + |> list.map(string.replace(_, "dot_", ".")) 230 + |> string.join("/") 231 + } 232 + 233 + // symlink ---------------------------------------------------------------------- 234 + 235 + /// creates a symlink based on a spec 236 + /// 237 + /// spec <- the spec 238 + /// home <- `/home/<user>` 239 + /// 240 + fn make_symlink_from_spec(spec spec: Spec, home home: String) { 241 + let Spec(dotfiles_path:, target_path:) = spec 242 + 243 + make_symlink( 244 + filepath.join(home, dotfiles_path), 245 + filepath.join(home, target_path), 246 + ) 247 + |> result.replace(spec) 248 + } 249 + 250 + /// creates a symlink from the `dotfiles` dir path to the original path for a given config 251 + /// Tries to make a backup of existing files at `target_path` by moving them to `target_path`.bak 252 + /// 253 + /// `dotfiles_path` <- the path in `~/dotfiles` -- ex: `/home/<user>/dotfiles/dot_config/nvim` 254 + /// `target_path` <- the original path -- ex: `/home/<user>/.config/nvim` 255 + /// 256 + fn make_symlink( 257 + dotfiles_path dotfiles_path: String, 258 + target_path target_path: String, 259 + ) -> Result(String, InternalError) { 260 + // if the file already exists, and isnt a symlink, make a backup 261 + let _ = case simplifile.is_symlink(target_path) { 262 + Ok(False) -> simplifile.rename(target_path, target_path <> ".bak") 263 + _ -> Ok(Nil) 264 + } 265 + 266 + shellout.command( 267 + run: "ln", 268 + with: [ 269 + "--symbolic", 270 + "--no-dereference", 271 + "--force", 272 + dotfiles_path |> echo, 273 + target_path |> echo, 274 + ], 275 + in: ".", 276 + opt: [], 277 + ) 278 + |> result.map_error(FailedToCreateSymlink) 279 + } 280 + 281 + // spec ------------------------------------------------------------------------- 282 + 283 + type Spec { 284 + /// both without the /home/<user> for portability 285 + /// `dotfiles_path` <- the path in `~/dotfiles/dot_config/nvim/` 286 + /// `target_path` <- the path the config belongs in i.e. `~/.config/nvim` 287 + /// 288 + Spec(dotfiles_path: String, target_path: String) 289 + } 290 + 291 + fn spec_from_config_path(path: String) -> Result(Spec, InternalError) { 292 + use path <- result.try(case path { 293 + "/home" <> _ -> drop_home(path) |> Ok 294 + _ -> Error(FileNotInHomeDirectory(path)) 295 + }) 296 + 297 + Spec(to_dotfiles_path(path), path) |> Ok 298 + } 299 + 300 + /// gets path of spec.json using `/home/<user>` 301 + /// 302 + fn spec_path(home home) { 303 + home 304 + |> filepath.join(dotfiles) 305 + |> filepath.join("spec.json") 306 + } 307 + 308 + /// saves provided spec to default spec location 309 + /// spec <- spec to save 310 + /// home <- `/home/<user>/` 311 + /// 312 + fn persist_spec(spec: Spec, home: String) -> Result(Nil, InternalError) { 313 + use specs <- result.try( 314 + spec_path(home) 315 + |> load_specs, 316 + ) 317 + write_specs_to_file([spec, ..specs], home) 318 + } 319 + 320 + /// saves provided list of specs to default spec location 321 + /// specs <- list of specs to save 322 + /// home <- `/home/<user>/` 323 + /// 324 + fn persist_specs(specs: List(Spec), home: String) -> Result(Nil, InternalError) { 325 + use old_specs <- result.try( 326 + spec_path(home) 327 + |> load_specs, 328 + ) 329 + 330 + list.append(old_specs, specs) 331 + |> write_specs_to_file(home) 332 + } 333 + 334 + /// overwrites the existing spec file with the supplied specs 335 + /// 336 + fn write_specs_to_file(specs specs: List(Spec), home home: String) { 337 + let json_string = 338 + json.array(list.unique(specs), spec_to_json) 339 + |> json.to_string() 340 + 341 + simplifile.write(json_string, to: spec_path(home)) 342 + |> result.map_error(FailedToWrite(spec_path(home), _)) 343 + } 344 + 345 + /// tries to load a list of specs from the given location 346 + /// 347 + fn load_specs(from from: String) -> Result(List(Spec), InternalError) { 348 + let file = 349 + simplifile.read(from) 350 + |> result.map_error(FailedToRead(from, _)) 351 + case file { 352 + // file doesnt exist yet 353 + Error(FailedToRead(_, simplifile.Enoent)) -> Ok("[]") 354 + Ok(content) -> Ok(content) 355 + e -> e 356 + } 357 + |> result.try(fn(contents) { 358 + json.parse(contents, decode.list(spec_decoder())) 359 + |> result.map_error(FailedToDecode) 360 + }) 361 + } 362 + 363 + fn spec_to_json(spec spec: Spec) -> json.Json { 364 + let Spec(dotfiles_path:, target_path:) = spec 365 + json.object([ 366 + #("dotfiles_path", json.string(dotfiles_path)), 367 + #("target_path", json.string(target_path)), 368 + ]) 369 + } 370 + 371 + fn spec_decoder() -> decode.Decoder(Spec) { 372 + use dotfiles_path <- decode.field("dotfiles_path", decode.string) 373 + use target_path <- decode.field("target_path", decode.string) 374 + decode.success(Spec(dotfiles_path:, target_path:)) 375 + }
+13
test/dotfiles_test.gleam
··· 1 + import gleeunit 2 + 3 + pub fn main() -> Nil { 4 + gleeunit.main() 5 + } 6 + 7 + // gleeunit test functions end in `_test` 8 + pub fn hello_world_test() { 9 + let name = "Joe" 10 + let greeting = "Hello, " <> name <> "!" 11 + 12 + assert greeting == "Hello, Joe!" 13 + }