Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ a new terminal and just type `cain`. The repo can live anywhere: cain remembers
### Usage

```
cain asks: Collect (C) or Install (I)?
cain menu: Install or Collect
cain -c collect: copy this machine's configs into the repo, commit, push
cain -i install everything
cain -i menu: pick what to install (see below)
cain -i -y install everything, no menu
cain install light pick a preset without the menu: full light dotfiles keybindings server
cain install keys only some steps: packages git files bin shell keys desktop
cain keys re-apply keybindings after editing cain.json
cain generate macos print the keyd / AutoHotkey / Karabiner config for any OS
Expand All @@ -91,6 +93,22 @@ cain booklister pick a book from ~/Desktop/books and open it ($CAIN_BOOKS_
cain listlauncher edit list.html on the website, then commit and push ($CAIN_SITE_DIR)
```

`cain -i` opens a menu (arrow keys or j/k, Enter to pick, q to quit):

```
What should cain install?

> Full programs, dotfiles, keybindings and desktop settings
Light dotfiles and keybindings; no programs, no desktop changes
Dotfiles just configs, shell aliases and git settings
Keybindings just key remaps and shortcuts
Server terminal programs and dotfiles, nothing graphical (Linux)
Custom tick each part yourself
```

Custom shows a checklist of the install steps (Space ticks, `a` toggles all). When cain isn't
run from a terminal (a script, CI) it skips the menu and installs everything, like `-y`.

Files that install overwrites are backed up to `~/.cain-backup/<date>/` first.

### Per-OS notes
Expand Down Expand Up @@ -132,7 +150,7 @@ make build ./cain
make test check the generated configs against tests/expected/
make windows cross-compile cain.exe with MinGW
```
The code is in `src/`: `main.c` (commands and install steps), `keys.c` (key names for
The code is in `src/`: `main.c` (commands, install steps and presets), `tui.c` (the menu), `keys.c` (key names for
every backend), `keyd.c` / `ahk.c` / `karabiner.c` / `linux_desktop.c` (the backends),
`packages.c`, `desktop.c`, `tools.c` (booklister, listlauncher), `json.c` and `util.c`.
GitHub Actions builds and tests on all three OSes. Push a `v*` tag to publish binaries.
Expand Down
7 changes: 7 additions & 0 deletions src/cain.h
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ void packages_install(const Manifest *m, const char *os, int terminal_only);
void desktop_apply(const char *os, const char *desktop, const Manifest *m);
int collect_cinnamon_settings(void);

/* ------------------------------------------------------------------- tui.c */

typedef struct { const char *label, *desc; } MenuItem;
/* Arrow-key menu. Returns the chosen index, or -1 if cancelled / stdin isn't a terminal.
* With `checked` (one int per item) it's a checklist: Space ticks, Enter confirms. */
int tui_menu(const char *title, const MenuItem *items, int n, int start, int *checked);

/* ----------------------------------------------------------------- tools.c */

int booklister_main(void);
Expand Down
111 changes: 104 additions & 7 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ static const char *USAGE =
"\n"
" cain ask whether to collect or install\n"
" cain -c | collect copy this machine's configs into the repo, commit and push\n"
" cain -i | install install programs, configs, keybindings and desktop settings\n"
" cain install keys run only some install steps:\n"
" packages git files bin shell keys desktop\n"
" cain -i | install pick what to install from a menu (full, light, dotfiles, ...)\n"
" cain install light skip the menu: full light dotfiles keybindings server\n"
" cain install keys or name install steps: packages git files bin shell keys desktop\n"
" -y, --yes install everything without the menu\n"
" cain keys shorthand for `cain install keys`\n"
" cain vps set up a Linux server: terminal programs, configs and shell, no desktop\n"
" cain vps shell run only some vps steps: packages git files bin shell\n"
Expand All @@ -44,6 +45,80 @@ static const char *const STEPS[] = {"packages", "git", "files", "bin", "shell",
static const char *const VPS_STEPS[] = {"packages", "git", "files", "bin", "shell", NULL};
static int g_vps = 0;

/* What the install menu offers. `cain install <name>` picks one without the menu. */
typedef struct { const char *name, *label, *desc, *steps; int vps; } Preset;
static const Preset PRESETS[] = {
{"full", "Full", "programs, dotfiles, keybindings and desktop settings", "packages git files bin shell keys desktop", 0},
{"light", "Light", "dotfiles and keybindings; no programs, no desktop changes", "git files bin shell keys", 0},
{"dotfiles", "Dotfiles", "just configs, shell aliases and git settings", "git files bin shell", 0},
{"keybindings", "Keybindings", "just key remaps and shortcuts", "keys", 0},
{"server", "Server", "terminal programs and dotfiles, nothing graphical", "packages git files bin shell", 1},
{NULL, NULL, NULL, NULL, 0},
};

static const char *const STEP_DESCS[] = {
"programs from cain.json",
"your git name and email",
"dotfiles: micro, nano, shell aliases...",
"put the cain command on your PATH",
"load the aliases from your shell's startup file",
"key remaps and keyboard shortcuts",
"wallpaper, dark mode, desktop theme",
};

static void push_words(StrList *l, const char *words)
{
char *copy = xstrdup(words);
for (char *t = strtok(copy, " "); t; t = strtok(NULL, " "))
if (!sl_has(l, t)) sl_push(l, t);
free(copy);
}

static void use_preset(const Preset *p, StrList *steps)
{
if (p->vps) {
if (!streq(OS_NATIVE, "linux")) die("the server install is for Linux");
g_vps = 1;
}
push_words(steps, p->steps);
}

/* The install menu. 0 if the user backed out. */
static int pick_install(StrList *steps)
{
MenuItem items[16];
const Preset *map[16];
int n = 0;
for (int i = 0; PRESETS[i].name; i++) {
if (PRESETS[i].vps && !streq(OS_NATIVE, "linux")) continue;
items[n].label = PRESETS[i].label;
items[n].desc = PRESETS[i].desc;
map[n++] = &PRESETS[i];
}
items[n].label = "Custom";
items[n].desc = "tick each part yourself";
int custom = n++;

int choice = tui_menu("What should cain install?", items, n, 0, NULL);
if (choice < 0) return 0;
if (choice != custom) {
use_preset(map[choice], steps);
return 1;
}

MenuItem parts[8];
int checked[8], nsteps = 0;
for (; STEPS[nsteps]; nsteps++) {
parts[nsteps].label = STEPS[nsteps];
parts[nsteps].desc = STEP_DESCS[nsteps];
checked[nsteps] = 1;
}
if (tui_menu("Pick what to install", parts, nsteps, 0, checked) < 0) return 0;
for (int i = 0; i < nsteps; i++)
if (checked[i]) sl_push(steps, STEPS[i]);
return steps->n > 0;
}

/* ------------------------------------------------------------------ finding the repo */

static int has_manifest(const char *dir)
Expand Down Expand Up @@ -374,11 +449,13 @@ int main(int argc, char **argv)
{
const char *command = NULL;
StrList args = {0};
int yes = 0;
for (int i = 1; i < argc; i++) {
const char *a = argv[i];
if (streq(a, "-n") || streq(a, "--dry-run")) g_dry_run = 1;
else if (streq(a, "-c") || streq(a, "--collect")) command = "collect";
else if (streq(a, "-i") || streq(a, "--install")) command = "install";
else if (streq(a, "-y") || streq(a, "--yes") || streq(a, "--all")) yes = 1;
else if (streq(a, "-h") || streq(a, "--help") || streq(a, "help")) { fputs(USAGE, stdout); return 0; }
else if (a[0] == '-') { fprintf(stderr, "cain: unknown option %s\n\n%s", a, USAGE); return 2; }
else if (!command) command = a;
Expand All @@ -402,6 +479,15 @@ int main(int argc, char **argv)

fputs(ASCII, stdout);
putchar('\n');
if (!command && stdin_tty()) {
const MenuItem items[] = {
{"Install", "put my programs, configs and keybindings on this machine"},
{"Collect", "copy this machine's configs into the repo, commit and push"},
};
int choice = tui_menu("What do you want to do?", items, 2, 0, NULL);
if (choice < 0) return 0;
command = choice == 0 ? "install" : "collect";
}
while (!command) {
printf("Would you like to Collect (C) or Install (I)?: ");
fflush(stdout);
Expand All @@ -421,11 +507,22 @@ int main(int argc, char **argv)
if (streq(command, "collect") || streq(command, "c")) {
collect(m);
} else if (streq(command, "install") || streq(command, "i") || streq(command, "keys") || g_vps) {
const char *const *allowed = g_vps ? VPS_STEPS : STEPS;
StrList steps = {0};
if (args.n) steps = args;
else if (streq(command, "keys")) sl_push(&steps, "keys");
else for (int i = 0; allowed[i]; i++) sl_push(&steps, allowed[i]);
for (int i = 0; i < args.n; i++) { /* preset names expand to their steps */
const Preset *p = PRESETS;
while (p->name && (g_vps || !streq(p->name, args.v[i]))) p++;
if (p->name) use_preset(p, &steps);
else if (!sl_has(&steps, args.v[i])) sl_push(&steps, args.v[i]);
}
int menu = !steps.n && !streq(command, "keys") && !g_vps && !yes && stdin_tty();
if (menu && !pick_install(&steps)) {
info("Nothing installed.");
return 0;
}
const char *const *allowed = g_vps ? VPS_STEPS : STEPS;
if (!steps.n && streq(command, "keys")) sl_push(&steps, "keys");
if (!steps.n)
for (int i = 0; allowed[i]; i++) sl_push(&steps, allowed[i]);
for (int i = 0; i < steps.n; i++) {
int known = 0;
for (int k = 0; allowed[k]; k++) known |= streq(allowed[k], steps.v[i]);
Expand Down
Loading
Loading