❄ Personal NixOS Flake Manager
nixos
home-manager
go
nix
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "time"
9
10 "github.com/urfave/cli/v2"
11)
12
13type Configuration struct {
14 Notify bool `json:"notify"`
15 Editor string `json:"editor"`
16 Flake string `json:"flake"`
17 Notifier string `json:"notifier"`
18 AllowUnfree bool `json:"allow-unfree"`
19 AllowInsecure bool `json:"allow-insecure"`
20 ExtraArgs []string `json:"extra-args"`
21}
22
23type ActionDetails struct {
24 Name string
25 Verb string
26 UsableWithNH bool
27}
28
29var configuration Configuration
30
31const (
32 Switch = iota
33 Boot
34 Test
35 Build
36 DryActivate
37 BuildVM
38 Instantiate
39 Generations
40 Packages
41)
42
43func init() {
44 configurationPath := os.Getenv("RUI_CONFIG")
45
46 if configurationPath == "" {
47 configurationPath = os.Getenv("XDG_CONFIG_HOME") + "/rui/config.json"
48 }
49
50 content, err := os.ReadFile(configurationPath)
51
52 if err != nil {
53 return
54 }
55
56 if err := json.Unmarshal(content, &configuration); err != nil {
57 return
58 }
59}
60
61func subcommand(action int, aliases []string, flags []cli.Flag, commandAction func(c *cli.Context, action int) error) *cli.Command {
62 return &cli.Command{
63 Name: actionName(action),
64 Aliases: aliases,
65 Flags: flags,
66 Action: func(c *cli.Context) error {
67 return commandAction(c, action)
68 },
69 }
70}
71
72func main() {
73 homeFlags := []cli.Flag{
74 &cli.BoolFlag{
75 Name: "use-home-manager",
76 },
77 &cli.StringFlag{
78 Name: "user",
79 },
80 }
81 osFlags := []cli.Flag{
82 &cli.BoolFlag{
83 Name: "use-nixos-rebuild",
84 },
85 &cli.StringFlag{
86 Name: "hostname",
87 },
88 }
89
90 if err := (&cli.App{
91 Name: "rui",
92 Usage: "Personal NixOS Flake Manager",
93 Description: "Personal NixOS Flake Manager",
94 EnableBashCompletion: true,
95 Authors: []*cli.Author{
96 {
97 Name: "Fuwn",
98 Email: "contact@fuwn.me",
99 },
100 },
101 Copyright: fmt.Sprintf("Copyright (c) 2024-%s Fuwn", fmt.Sprint(time.Now().Year())),
102 ExitErrHandler: func(c *cli.Context, err error) {
103 if err != nil {
104 fmt.Println(err)
105 os.Exit(1)
106 }
107 },
108 Suggest: true,
109 Flags: []cli.Flag{
110 &cli.BoolFlag{
111 Name: "allow-unfree",
112 Action: func(c *cli.Context, b bool) error {
113 state := "0"
114
115 if b {
116 state = "1"
117 }
118
119 return os.Setenv("NIXPKGS_ALLOW_UNFREE", state)
120 },
121 },
122 &cli.BoolFlag{
123 Name: "allow-insecure",
124 Action: func(c *cli.Context, b bool) error {
125 state := "0"
126
127 if b {
128 state = "1"
129 }
130
131 return os.Setenv("NIXPKGS_ALLOW_INSECURE", state)
132 },
133 },
134 },
135 Before: func(c *cli.Context) error {
136 if configuration.AllowUnfree {
137 c.Set("allow-unfree", "1")
138 }
139
140 if configuration.AllowInsecure {
141 c.Set("allow-insecure", "1")
142 }
143
144 return nil
145 },
146 Commands: []*cli.Command{
147 {
148 Name: "hs",
149 Action: func(c *cli.Context) error {
150 return c.App.Command("home").Command("switch").Run(c)
151 },
152 Hidden: true,
153 Description: "Alias for `home switch`",
154 },
155 {
156 Name: "osw",
157 Action: func(c *cli.Context) error {
158 return c.App.Command("os").Command("switch").Run(c)
159 },
160 Hidden: true,
161 Usage: "Alias for `os switch`",
162 },
163 {
164 Name: "home",
165 Subcommands: []*cli.Command{
166 subcommand(Switch, []string{"sw"}, homeFlags, home),
167 subcommand(Build, []string{}, homeFlags, home),
168 subcommand(Instantiate, []string{}, homeFlags, home),
169 subcommand(Generations, []string{"gens"}, homeFlags, home),
170 subcommand(Packages, []string{"pkgs"}, homeFlags, home),
171 {
172 Name: "news",
173 Flags: []cli.Flag{
174 &cli.StringFlag{
175 Name: "user",
176 },
177 },
178 Action: func(c *cli.Context) error {
179 flake := configuration.Flake
180 extraArgs := c.Args().Slice()
181
182 extraArgs = append(extraArgs, configuration.ExtraArgs...)
183
184 if flake == "" {
185 flake = os.Getenv("FLAKE")
186 }
187
188 if user := c.String("user"); user != "" {
189 flake = fmt.Sprintf("%s#%s", flake, user)
190 }
191
192 return command("home-manager", append([]string{"news", "--flake",
193 flake}, extraArgs...)...)
194 },
195 },
196 },
197 },
198 {
199 Name: "os",
200 Subcommands: []*cli.Command{
201 subcommand(Switch, []string{"sw"}, osFlags, ruiOS),
202 subcommand(Boot, []string{}, osFlags, ruiOS),
203 subcommand(Test, []string{}, osFlags, ruiOS),
204 subcommand(Build, []string{}, osFlags, ruiOS),
205 subcommand(DryActivate, []string{"dry"}, osFlags, ruiOS),
206 subcommand(BuildVM, []string{"vm"}, osFlags, ruiOS),
207 },
208 },
209 {
210 Name: "edit",
211 Action: func(c *cli.Context) error {
212 var found bool
213
214 editor := configuration.Editor
215 flake := configuration.Flake
216
217 if flake == "" {
218 flake = os.Getenv("FLAKE")
219 }
220
221 if editor == "" {
222 if editor, found = os.LookupEnv("FLAKE_EDITOR"); !found {
223 editor = os.Getenv("EDITOR")
224 }
225 }
226
227 return command(editor, flake)
228 },
229 },
230 },
231 }).Run(os.Args); err != nil {
232 fmt.Println(err)
233 os.Exit(1)
234 }
235}
236
237func command(name string, args ...string) error {
238 cmd := exec.Command(name, args...)
239 cmd.Stdin = os.Stdin
240 cmd.Stdout = os.Stdout
241 cmd.Stderr = os.Stderr
242
243 return cmd.Run()
244}
245
246func notify(message string) error {
247 if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
248 return nil
249 }
250
251 notifier := configuration.Notifier
252
253 if notifier == "" {
254 notifier = "notify-send"
255 }
256
257 notifySend, err := exec.LookPath(notifier)
258
259 if err != nil {
260 return nil
261 }
262
263 if configuration.Notify {
264 return command(notifySend, "Rui", message)
265 }
266
267 return nil
268}
269
270func actionName(action int) string {
271 name := "switch"
272
273 switch action {
274 case Boot:
275 name = "boot"
276
277 break
278
279 case Test:
280 name = "test"
281
282 break
283
284 case Build:
285 name = "build"
286
287 break
288
289 case DryActivate:
290 name = "dry-activate"
291
292 break
293
294 case BuildVM:
295 name = "build-vm"
296
297 break
298
299 case Instantiate:
300 name = "instantiate"
301
302 break
303
304 case Generations:
305 name = "generations"
306
307 break
308
309 case Packages:
310 name = "packages"
311
312 break
313 }
314
315 return name
316}
317
318func actionDetails(action int) (string, string, bool) {
319 switch action {
320 case Switch:
321 return actionName(action), "switched", true
322
323 case Boot:
324 return actionName(action), "booted", false
325
326 case Test:
327 return actionName(action), "tested", false
328
329 case Build:
330 return actionName(action), "built", true
331
332 case DryActivate:
333 return actionName(action), "dry activated", false
334
335 case BuildVM:
336 return actionName(action), "VM built", false
337
338 case Instantiate:
339 return actionName(action), "instantiated", false
340
341 case Generations:
342 return actionName(action), "generations listed", false
343
344 case Packages:
345 return actionName(action), "packages shown", false
346 }
347
348 return "", "", false
349}
350
351func home(c *cli.Context, action int) error {
352 nh, err := exec.LookPath("nh")
353 extraArgs := c.Args().Slice()
354 name, verb, usableWithNH := actionDetails(action)
355
356 extraArgs = append(extraArgs, configuration.ExtraArgs...)
357
358 if err := notify("Queued home " + name); err != nil {
359 return err
360 }
361
362 if err == nil && !c.Bool("use-home-manager") {
363 if !usableWithNH {
364 return fmt.Errorf("This command is not supported with nh. Use --use-home-manager to use Home Manager instead.")
365 }
366
367 err = command(nh, append([]string{"home", name, "--"},
368 extraArgs...)...)
369 } else {
370 user := c.String("user")
371
372 if user == "" {
373 user = os.Getenv("USER")
374 }
375
376 flake := configuration.Flake
377
378 if flake == "" {
379 flake = os.Getenv("FLAKE")
380 }
381
382 err = command("home-manager", append([]string{name,
383 "--flake", fmt.Sprintf("%s#%s", flake, user)},
384 extraArgs...)...)
385 }
386
387 if err != nil {
388 return notify(fmt.Sprintf("Failed to %s home: %s", name, err.Error()))
389 }
390
391 return notify("Home " + verb)
392}
393
394func ruiOS(c *cli.Context, action int) error {
395 nh, err := exec.LookPath("nh")
396 name, verb, usableWithNH := actionDetails(action)
397
398 if err := notify("Queued OS " + name); err != nil {
399 return err
400 }
401
402 if err == nil && !c.Bool("use-nixos-rebuild") {
403 if !usableWithNH {
404 return fmt.Errorf("This command is not supported with nh. Use --use-nixos-rebuild to use nixos-rebuild instead.")
405 }
406
407 err = command(nh, "os", name)
408 } else {
409 escalator := "sudo"
410
411 if doas, err := exec.LookPath("doas"); err != nil {
412 escalator = doas
413 }
414
415 hostname := c.String("hostname")
416
417 if hostname == "" {
418 hostname, err = os.Hostname()
419
420 if err != nil {
421 return err
422 }
423 }
424
425 flake := configuration.Flake
426
427 if flake == "" {
428 flake = os.Getenv("FLAKE")
429 }
430
431 err = command(escalator, "nixos-rebuild", name, "--flake",
432 fmt.Sprintf("%s#%s", flake, hostname))
433 }
434
435 if err != nil {
436 return notify(fmt.Sprintf("Failed to %s OS: %s", name, err.Error()))
437 }
438
439 return notify("OS " + verb)
440}