···11+# dotfile_helper
22+33+### Features
44+55+- new | set up a fresh repo in ~/dotfiles
66+- init | initialize symlinks based on existing ~/dotfiles repo
77+- init <link> | like init, but clones the supplied repo to ~/dotfiles first
88+- add <path> | add an existing dot file to the ~/dotfiles repo and set up the symlink
99+- add submodule <link> <path> | like add, but it will be added as a submodule
1010+1111+TODO: builds and releases
1212+1313+---
1414+#### Usage examples
1515+1616+Initial setup
1717+```sh
1818+gleam run new
1919+```
2020+2121+Adding a local config
2222+```sh
2323+gleam run add ~/.config/wezterm
2424+```
2525+2626+Adding a config that is already tracked elsewhere
2727+```sh
2828+gleam run add submodule git@git.nuv.sh:nuv/nvim ~/.config/nvim
2929+```
3030+The link `git@git.nuv.sh:nuv/nvim` and the path `~/.config/nvim` are directly passed to
3131+`git submodule add <link> <path>` running in `~/dotfiles`
3232+3333+3434+Setting up a new device with an existing dotfiles repo
3535+```sh
3636+gleam run init git@git.nuv.sh:nuv/dotfiles
3737+```
3838+3939+
+25
gleam.toml
···11+name = "dotfiles"
22+version = "1.0.0"
33+44+# Fill out these fields if you intend to generate HTML documentation or publish
55+# your project to the Hex package manager.
66+#
77+# description = ""
88+# licences = ["Apache-2.0"]
99+# repository = { type = "github", user = "", repo = "" }
1010+# links = [{ title = "Website", href = "" }]
1111+#
1212+# For a full reference of all the available options, you can have a look at
1313+# https://gleam.run/writing-gleam/gleam-toml/.
1414+1515+[dependencies]
1616+gleam_stdlib = ">= 0.44.0 and < 2.0.0"
1717+simplifile = ">= 2.3.0 and < 3.0.0"
1818+filepath = ">= 1.1.2 and < 2.0.0"
1919+shellout = ">= 1.7.0 and < 2.0.0"
2020+argv = ">= 1.0.2 and < 2.0.0"
2121+envoy = ">= 1.0.2 and < 2.0.0"
2222+gleam_json = ">= 3.0.2 and < 4.0.0"
2323+2424+[dev-dependencies]
2525+gleeunit = ">= 1.0.0 and < 2.0.0"
+23
manifest.toml
···11+# This file was generated by Gleam
22+# You typically do not need to edit this file
33+44+packages = [
55+ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
66+ { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
77+ { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
88+ { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
99+ { name = "gleam_stdlib", version = "0.64.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "EA2E13FC4E65750643E078487D5EF360BEBCA5EBBBA12042FB589C19F53E35C0" },
1010+ { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
1111+ { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" },
1212+ { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
1313+]
1414+1515+[requirements]
1616+argv = { version = ">= 1.0.2 and < 2.0.0" }
1717+envoy = { version = ">= 1.0.2 and < 2.0.0" }
1818+filepath = { version = ">= 1.1.2 and < 2.0.0" }
1919+gleam_json = { version = ">= 3.0.2 and < 4.0.0" }
2020+gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
2121+gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
2222+shellout = { version = ">= 1.7.0 and < 2.0.0" }
2323+simplifile = { version = ">= 2.3.0 and < 3.0.0" }
+375
src/dotfiles.gleam
···11+import argv
22+import envoy
33+import filepath
44+import gleam/dynamic/decode
55+import gleam/io
66+import gleam/json
77+import gleam/list
88+import gleam/result
99+import gleam/string
1010+import shellout
1111+import simplifile
1212+1313+/// location, relative to $HOME, of the dotfiles repo
1414+const dotfiles = "dotfiles"
1515+1616+type InternalError {
1717+ FailedToCopy(path: String, error: simplifile.FileError)
1818+ FailedToRead(path: String, error: simplifile.FileError)
1919+ FailedToWrite(path: String, error: simplifile.FileError)
2020+ FileNotInHomeDirectory(file: String)
2121+ FailedToCreateSymlink(#(Int, String))
2222+ FailedToDecode(json.DecodeError)
2323+ FailedToCloneRepo(#(Int, String))
2424+ FailedToInitRepo(#(Int, String))
2525+ FailedToCreateDir(path: String, error: simplifile.FileError)
2626+}
2727+2828+fn describe_error(error: InternalError) {
2929+ case error {
3030+ FailedToCloneRepo(_) -> string.inspect(error)
3131+ FailedToCopy(path, inner) ->
3232+ string.inspect(error)
3333+ <> " "
3434+ <> path
3535+ <> " "
3636+ <> simplifile.describe_error(inner)
3737+ FailedToCreateSymlink(_) -> string.inspect(error)
3838+ FailedToDecode(_) -> string.inspect(error)
3939+ FailedToRead(path, inner) ->
4040+ string.inspect(error)
4141+ <> " "
4242+ <> path
4343+ <> " "
4444+ <> simplifile.describe_error(inner)
4545+ FailedToWrite(path, inner) ->
4646+ string.inspect(error)
4747+ <> " "
4848+ <> path
4949+ <> " "
5050+ <> simplifile.describe_error(inner)
5151+ FileNotInHomeDirectory(_) -> string.inspect(error)
5252+ FailedToCreateDir(path, inner) ->
5353+ string.inspect(error)
5454+ <> " "
5555+ <> path
5656+ <> " "
5757+ <> simplifile.describe_error(inner)
5858+ FailedToInitRepo(_) -> string.inspect(error)
5959+ }
6060+}
6161+6262+pub fn main() -> Nil {
6363+ let program_args = argv.load().arguments
6464+ let assert Ok(home_dir) = envoy.get("HOME") as "$HOME not defined"
6565+6666+ case program_args {
6767+ ["help"] -> show_help()
6868+ ["new"] -> {
6969+ let _ =
7070+ setup_new(home_dir)
7171+ |> result.map_error(describe_error)
7272+ |> result.map_error(io.println_error)
7373+7474+ Nil
7575+ }
7676+7777+ ["init", link] -> {
7878+ let _ =
7979+ init_from_link(link, home_dir)
8080+ |> result.map_error(describe_error)
8181+ |> result.map_error(io.println_error)
8282+8383+ Nil
8484+ }
8585+ ["init"] -> {
8686+ let _ =
8787+ update_symlinks(home_dir)
8888+ |> result.map_error(describe_error)
8989+ |> result.map_error(io.println_error)
9090+9191+ Nil
9292+ }
9393+ ["add", "submodule", link, path] -> {
9494+ let _ =
9595+ add_submodule(home_dir, link, path)
9696+ |> result.map_error(describe_error)
9797+ |> result.map_error(io.println_error)
9898+ Nil
9999+ }
100100+ ["add", ..rest] -> {
101101+ let _ =
102102+ add_many(home_dir, rest)
103103+ |> result.map_error(describe_error)
104104+ |> result.map_error(io.println_error)
105105+ Nil
106106+ }
107107+ _ -> show_help()
108108+ }
109109+}
110110+111111+fn setup_new(home_dir: String) {
112112+ let path = filepath.join(home_dir, dotfiles)
113113+ use _ <- result.try(
114114+ simplifile.create_directory(path)
115115+ |> result.map_error(FailedToCreateDir(path, _)),
116116+ )
117117+118118+ shellout.command(run: "git", with: ["init"], in: path, opt: [])
119119+ |> result.map_error(FailedToInitRepo)
120120+}
121121+122122+/// git clone's the provided link and then runs normal setup
123123+///
124124+fn init_from_link(link: String, home: String) {
125125+ use _ <- result.try(clone_repo(link, home))
126126+ update_symlinks(home)
127127+}
128128+129129+fn clone_repo(link: String, home: String) {
130130+ shellout.command(
131131+ run: "git",
132132+ with: ["clone", "--recurse-submodules", link, filepath.join(home, dotfiles)],
133133+ in: ".",
134134+ opt: [],
135135+ )
136136+ |> result.map_error(FailedToCloneRepo)
137137+}
138138+139139+fn add_submodule(home: String, link: String, config_path: String) {
140140+ use spec <- result.try(spec_from_config_path(config_path))
141141+142142+ use _ <- result.try(
143143+ shellout.command(
144144+ run: "git",
145145+ with: ["submodule", "add", link, filepath.join(home, spec.dotfiles_path)],
146146+ in: filepath.join(home, dotfiles),
147147+ opt: [],
148148+ )
149149+ |> result.map_error(FailedToCloneRepo),
150150+ )
151151+152152+ use _ <- result.try(make_symlink_from_spec(spec, home))
153153+ persist_spec(spec, home)
154154+}
155155+156156+fn show_help() {
157157+ io.println(
158158+ "
159159+help -> show this
160160+new -> set up a fresh ~/dotfiles repo
161161+init -> initial setup of symlinks from ~/dotfiles to actual file locations
162162+init <link> -> like init, but clones the specified repo to ~/dotfiles as well
163163+add <path> -> add new config directory or file
164164+add submodule <link> <path> -> where <path> is the target path i.e. `~/.config/nvim`
165165+ ",
166166+ )
167167+}
168168+169169+/// add a list of new configs
170170+///
171171+fn add_many(home: String, configs: List(String)) {
172172+ configs
173173+ |> list.map(spec_from_config_path)
174174+ |> list.map(result.try(_, move_config_to_dotfiles(_, home)))
175175+ |> list.map(result.try(_, make_symlink_from_spec(_, home)))
176176+ |> list.map(result.map_error(_, describe_error))
177177+ |> list.map(result.map_error(_, io.println_error))
178178+ |> result.values()
179179+ |> persist_specs(home)
180180+}
181181+182182+/// Moves the `target_path` to `dotfiles_path` using simplifile.rename
183183+/// Returns the original spec
184184+///
185185+fn move_config_to_dotfiles(spec: Spec, home) {
186186+ simplifile.rename(
187187+ filepath.join(home, spec.target_path),
188188+ filepath.join(home, spec.dotfiles_path),
189189+ )
190190+ |> result.map_error(FailedToCopy(filepath.join(home, spec.dotfiles_path), _))
191191+ |> result.replace(spec)
192192+}
193193+194194+/// loads specs from spec.json and tries to create the symlinks based on it
195195+///
196196+fn update_symlinks(home home: String) -> Result(List(String), InternalError) {
197197+ use specs <- result.try(
198198+ spec_path(home)
199199+ |> load_specs,
200200+ )
201201+202202+ specs
203203+ |> list.map(make_symlink_from_spec(_, home))
204204+ |> list.map(result.map(_, string.inspect))
205205+ |> result.all()
206206+}
207207+208208+// path manipulation ------------------------------------------------------------
209209+210210+fn drop_home(from) {
211211+ from
212212+ |> filepath.split()
213213+ // drop `/`, `home` and `<user>`
214214+ |> list.drop(3)
215215+ |> list.fold("", filepath.join)
216216+}
217217+218218+fn to_dotfiles_path(path) {
219219+ string.split(path, on: "/")
220220+ |> list.map(string.replace(_, ".", "dot_"))
221221+ |> string.join("/")
222222+ |> filepath.join(dotfiles, _)
223223+}
224224+225225+fn to_original_path(path) {
226226+ path
227227+ |> string.replace(dotfiles <> "/", "")
228228+ |> string.split(on: "/")
229229+ |> list.map(string.replace(_, "dot_", "."))
230230+ |> string.join("/")
231231+}
232232+233233+// symlink ----------------------------------------------------------------------
234234+235235+/// creates a symlink based on a spec
236236+///
237237+/// spec <- the spec
238238+/// home <- `/home/<user>`
239239+///
240240+fn make_symlink_from_spec(spec spec: Spec, home home: String) {
241241+ let Spec(dotfiles_path:, target_path:) = spec
242242+243243+ make_symlink(
244244+ filepath.join(home, dotfiles_path),
245245+ filepath.join(home, target_path),
246246+ )
247247+ |> result.replace(spec)
248248+}
249249+250250+/// creates a symlink from the `dotfiles` dir path to the original path for a given config
251251+/// Tries to make a backup of existing files at `target_path` by moving them to `target_path`.bak
252252+///
253253+/// `dotfiles_path` <- the path in `~/dotfiles` -- ex: `/home/<user>/dotfiles/dot_config/nvim`
254254+/// `target_path` <- the original path -- ex: `/home/<user>/.config/nvim`
255255+///
256256+fn make_symlink(
257257+ dotfiles_path dotfiles_path: String,
258258+ target_path target_path: String,
259259+) -> Result(String, InternalError) {
260260+ // if the file already exists, and isnt a symlink, make a backup
261261+ let _ = case simplifile.is_symlink(target_path) {
262262+ Ok(False) -> simplifile.rename(target_path, target_path <> ".bak")
263263+ _ -> Ok(Nil)
264264+ }
265265+266266+ shellout.command(
267267+ run: "ln",
268268+ with: [
269269+ "--symbolic",
270270+ "--no-dereference",
271271+ "--force",
272272+ dotfiles_path |> echo,
273273+ target_path |> echo,
274274+ ],
275275+ in: ".",
276276+ opt: [],
277277+ )
278278+ |> result.map_error(FailedToCreateSymlink)
279279+}
280280+281281+// spec -------------------------------------------------------------------------
282282+283283+type Spec {
284284+ /// both without the /home/<user> for portability
285285+ /// `dotfiles_path` <- the path in `~/dotfiles/dot_config/nvim/`
286286+ /// `target_path` <- the path the config belongs in i.e. `~/.config/nvim`
287287+ ///
288288+ Spec(dotfiles_path: String, target_path: String)
289289+}
290290+291291+fn spec_from_config_path(path: String) -> Result(Spec, InternalError) {
292292+ use path <- result.try(case path {
293293+ "/home" <> _ -> drop_home(path) |> Ok
294294+ _ -> Error(FileNotInHomeDirectory(path))
295295+ })
296296+297297+ Spec(to_dotfiles_path(path), path) |> Ok
298298+}
299299+300300+/// gets path of spec.json using `/home/<user>`
301301+///
302302+fn spec_path(home home) {
303303+ home
304304+ |> filepath.join(dotfiles)
305305+ |> filepath.join("spec.json")
306306+}
307307+308308+/// saves provided spec to default spec location
309309+/// spec <- spec to save
310310+/// home <- `/home/<user>/`
311311+///
312312+fn persist_spec(spec: Spec, home: String) -> Result(Nil, InternalError) {
313313+ use specs <- result.try(
314314+ spec_path(home)
315315+ |> load_specs,
316316+ )
317317+ write_specs_to_file([spec, ..specs], home)
318318+}
319319+320320+/// saves provided list of specs to default spec location
321321+/// specs <- list of specs to save
322322+/// home <- `/home/<user>/`
323323+///
324324+fn persist_specs(specs: List(Spec), home: String) -> Result(Nil, InternalError) {
325325+ use old_specs <- result.try(
326326+ spec_path(home)
327327+ |> load_specs,
328328+ )
329329+330330+ list.append(old_specs, specs)
331331+ |> write_specs_to_file(home)
332332+}
333333+334334+/// overwrites the existing spec file with the supplied specs
335335+///
336336+fn write_specs_to_file(specs specs: List(Spec), home home: String) {
337337+ let json_string =
338338+ json.array(list.unique(specs), spec_to_json)
339339+ |> json.to_string()
340340+341341+ simplifile.write(json_string, to: spec_path(home))
342342+ |> result.map_error(FailedToWrite(spec_path(home), _))
343343+}
344344+345345+/// tries to load a list of specs from the given location
346346+///
347347+fn load_specs(from from: String) -> Result(List(Spec), InternalError) {
348348+ let file =
349349+ simplifile.read(from)
350350+ |> result.map_error(FailedToRead(from, _))
351351+ case file {
352352+ // file doesnt exist yet
353353+ Error(FailedToRead(_, simplifile.Enoent)) -> Ok("[]")
354354+ Ok(content) -> Ok(content)
355355+ e -> e
356356+ }
357357+ |> result.try(fn(contents) {
358358+ json.parse(contents, decode.list(spec_decoder()))
359359+ |> result.map_error(FailedToDecode)
360360+ })
361361+}
362362+363363+fn spec_to_json(spec spec: Spec) -> json.Json {
364364+ let Spec(dotfiles_path:, target_path:) = spec
365365+ json.object([
366366+ #("dotfiles_path", json.string(dotfiles_path)),
367367+ #("target_path", json.string(target_path)),
368368+ ])
369369+}
370370+371371+fn spec_decoder() -> decode.Decoder(Spec) {
372372+ use dotfiles_path <- decode.field("dotfiles_path", decode.string)
373373+ use target_path <- decode.field("target_path", decode.string)
374374+ decode.success(Spec(dotfiles_path:, target_path:))
375375+}
+13
test/dotfiles_test.gleam
···11+import gleeunit
22+33+pub fn main() -> Nil {
44+ gleeunit.main()
55+}
66+77+// gleeunit test functions end in `_test`
88+pub fn hello_world_test() {
99+ let name = "Joe"
1010+ let greeting = "Hello, " <> name <> "!"
1111+1212+ assert greeting == "Hello, Joe!"
1313+}