theamify-cli 1.0.1 β†’ 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # 🎨 theamify
2
2
 
3
3
  > **GRUB Theme Manager & Interactive Browser Wizard**
4
- > Browse, **preview (terminal thumbnails)**, download and apply GRUB boot themes β€” powered by the original `theamify` engine, now on npm as **`theamify-cli`**.
4
+ > Browse, **preview (terminal thumbnails)**, download and apply GRUB boot themes β€” now on npm as **`theamify-cli`**.
5
5
 
6
6
  ---
7
7
 
@@ -13,42 +13,49 @@
13
13
  npm install -g theamify-cli
14
14
  ```
15
15
 
16
- The `-g` global flag puts the `theamify` command on your system `PATH` (or provisions a user-local runtime), so you can call it from **any folder and any terminal**:
16
+ The `-g` flag puts the `theamify` command on your system `PATH` (or provisions a user-local runtime under `~/.local/share/theamify`), so you can call it from **any folder and any terminal**:
17
17
 
18
18
  ```bash
19
- theamify # ✨ interactive theme browser wizard
19
+ theamify # ✨ interactive wizard (detects tools β†’ browse β†’ preview β†’ apply)
20
20
  theamify list # list all themes with status
21
- theamify info <name> # details + terminal preview (needs chafa)
21
+ theamify info <name> # details + terminal preview
22
22
  theamify get <name> # download & cache a theme
23
23
  sudo theamify use <name> # apply to GRUB + rebuild
24
+ theamify update # update tools (chafa, grub-customizer) + re-download themes
24
25
  theamify status # GRUB & dependency status
25
- theamify doctor # diagnose install, GRUB & dependencies
26
- theamify uninstall # remove theamify (asks before deleting anything)
26
+ theamify doctor # diagnose install, GRUB, tools & dependencies
27
+ theamify uninstall # remove only theamify (keeps chafa & grub-customizer)
27
28
  ```
28
29
 
29
- > πŸ’‘ Already installed? Upgrading is the same command β€” npm's **global** install fetches the latest release and your downloaded themes & registry edits are preserved:
30
- >
31
- > ```bash
32
- > npm install -g theamify-cli@latest
33
- > ```
34
-
35
- > 🧰 Prefer a zero-install run (no permanent install)? Use npx directly:
36
- >
37
- > ```bash
38
- > npx -y theamify-cli
39
- > ```
30
+ > πŸ’‘ Upgrading is the same command β€” `npm install -g theamify-cli@latest`, and your downloaded themes & registry edits are preserved.
31
+ > 🧰 Zero-install run (no permanent install): `npx -y theamify-cli`
40
32
 
41
33
  ---
42
34
 
43
- ## ✨ What's New in v1.0.0 (npm wizard release)
35
+ ## ✨ What's New (v1.1.0 β€” npm release)
44
36
 
45
37
  | Feature | Details |
46
38
  |---|---|
47
- | πŸ§™ **Interactive browser wizard** | `theamify` with no args β†’ a clack-powered picker (mirroring GitSwitch) to **browse β†’ preview β†’ apply** |
48
- | πŸ–ΌοΈ **Terminal thumbnail preview** | Pick a theme and see its actual boot-screen preview inline via `chafa` before you commit |
49
- | πŸ“¦ **npm global install** | One command, run from anywhere β€” engine provisions itself under `~/.local/share/theamify` (writable, no sudo needed) |
50
- | 🩺 **`theamify doctor`** | Diagnose install, GRUB, active theme, registry & dependencies |
51
- | ⌨️ **Ctrl+C / Ctrl+Z safe** | Terminal signal safety net so interactive `sudo` during `use` is interruptible |
39
+ | πŸ§™ **Interactive wizard** | One command: detect companion tools β†’ pick theme β†’ **preview** β†’ apply |
40
+ | 🧩 **Auto-detected companion tools** | **chafa** (thumbnails) + **grub-customizer** (GUI) are detected first; installed if missing, or **option to update** if present β€” then it continues on its own |
41
+ | πŸ–ΌοΈ **Terminal thumbnail preview** | See the boot-screen image inline via `chafa` before you commit |
42
+ | πŸ“¦ **npm global install** | Engine provisions itself user-writable (no sudo for downloads) |
43
+ | 🩺 **`theamify doctor`** | Diagnose install, GRUB, active theme, registry, tools & dependencies |
44
+ | πŸ—‘ **Safe uninstall** | Removes **only theamify** β€” leaves chafa & grub-customizer for later use |
45
+ | ⌨️ **Ctrl+C / Ctrl+Z safe** | Signal safety net; interactive `sudo` is never frozen |
46
+
47
+ ---
48
+
49
+ ## 🧭 Wizard Sequence (every `theamify` run)
50
+
51
+ 1. **Detect companion tools** β€” `chafa` then `grub-customizer`. For each:
52
+ - **Already installed?** β†’ *β€œUpdate it now? (or just continue)”* β€” Continue moves on.
53
+ - **Missing?** β†’ *β€œInstall `<name>`? Yes/No”* β†’ installs via your package manager (`sudo`), then continues.
54
+ 2. **Show the theme picker** β€” every registry theme with status (`[REMOTE]`/`[CACHED]`/`[ACTIVE]`).
55
+ 3. **Pick a theme** β†’ optional **terminal thumbnail preview** (needs `chafa`).
56
+ 4. **Act** β€” download, apply to GRUB, or open in browser.
57
+
58
+ > Companion tools are installed strictly **before** the browser, so previews are ready when you need them.
52
59
 
53
60
  ---
54
61
 
@@ -60,14 +67,35 @@ theamify uninstall # remove theamify (asks before deleting anything)
60
67
  | `git` | Downloading / cloning themes |
61
68
  | `curl` **or** `wget` | Downloading |
62
69
  | `chafa` | Terminal thumbnail previews β€” **auto-installed by the wizard** |
63
- | `sudo` | Only applying a GRUB theme (`theamify use`) |
70
+ | `grub-customizer` | GUI to tweak GRUB menu β€” **auto-installed by the wizard** |
71
+ | `sudo` | Tool installs + applying a GRUB theme (`theamify use`) |
64
72
 
65
73
  ---
66
74
 
67
- ## Rosetta Stone β€” classic tool vs npm CLI
75
+ ## πŸ“ Project Structure (npm package)
76
+
77
+ ```
78
+ theamify/
79
+ β”œβ”€β”€ bin/theamify.js # npm bin entry β†’ src/cli.js
80
+ β”œβ”€β”€ src/
81
+ β”‚ β”œβ”€β”€ cli.js # command router + signal safety net
82
+ β”‚ β”œβ”€β”€ core/engine.js # runtime provisioning & engine forwarding
83
+ β”‚ β”œβ”€β”€ commands/manage.js # doctor / status / uninstall
84
+ β”‚ β”œβ”€β”€ wizard/browse.js # interactive wizard (browse β†’ preview β†’ apply)
85
+ β”‚ └── lib/
86
+ β”‚ β”œβ”€β”€ conf.js # themes.conf registry parser
87
+ β”‚ └── tools.js # companion tools: chafa + grub-customizer
88
+ β”œβ”€β”€ vendor/ # bundled bash engine (theamify + lib/ + config/)
89
+ β”œβ”€β”€ test/ # unit tests (node:test)
90
+ β”œβ”€β”€ package.json
91
+ └── .github/workflows/ # ci.yml + publish.yml (npm OIDC)
92
+ ```
68
93
 
69
- The npm CLI is a thin Node wrapper around the original Bash engine, so every
70
- non-interactive command still behaves identically:
94
+ ---
95
+
96
+ ## Rosetta Stone β€” CLI vs classic tool
97
+
98
+ The npm CLI is a thin wrapper around the original Bash engine; every non-interactive command behaves identically:
71
99
 
72
100
  | npm CLI | Original tool |
73
101
  |---|---|
@@ -77,31 +105,24 @@ non-interactive command still behaves identically:
77
105
  | `theamify info <n>` | `theamify info <n>` |
78
106
  | `theamify add/del/remove/open/update` | same names |
79
107
 
80
- `theamify <no args>` is where the wizard differs β€” it opens the interactive
81
- browser/preview/apply flow instead of the flat TUI menu.
108
+ `theamify <no args>` differs β€” it runs the interactive wizard (tools-then-browser) instead of the flat TUI menu.
82
109
 
83
110
  ---
84
111
 
85
- ## Theme Previews in the Terminal
112
+ ## 🧩 Companion Tools: chafa & grub-customizer
86
113
 
87
- chafa is a **first-class dependency of the wizard** β€” the first step of every
88
- wizard run ensures it's present (installing it when missing), updates it on
89
- `theamify update`, and removes it on `theamify uninstall`.
114
+ The wizard manages two companion tools as first-class:
90
115
 
91
- ```bash
92
- theamify # Step 1: ensures chafa (prompts to install if missing)
93
- ```
116
+ | Tool | Purpose | Install command (apt) | Lifecycle |
117
+ |---|---|---|---|
118
+ | `chafa` | Renders theme thumbnails in the terminal | `sudo apt install chafa` | auto-installed/updated by wizard |
119
+ | `grub-customizer` | GUI for GRUB menu & theme tweaks | `sudo apt install grub-customizer` | auto-installed/updated by wizard |
94
120
 
95
- Or check/install it directly via your package manager:
96
- ```bash
97
- sudo apt install chafa
98
- ```
99
-
100
- ```bash
101
- theamify # pick a theme β†’ auto-preview
102
- theamify info <name> # details + thumbnail preview
103
- theamify update # also updates chafa
104
- ```
121
+ **Behavior:**
122
+ - **Detect** β†’ if present, offer to update; if absent, install (with your consent).
123
+ - **Continue** regardless of Yes/No β€” the wizard proceeds to the theme picker.
124
+ - **`theamify update`** updates tools then re-downloads themes.
125
+ - **`theamify uninstall`** removes **only** theamify β€” chafa and grub-customizer stay on the system for later use.
105
126
 
106
127
  ---
107
128
 
@@ -120,167 +141,6 @@ theamify update # also updates chafa
120
141
 
121
142
  ---
122
143
 
123
- ## πŸ“ Project Structure (npm package)
124
-
125
- ```
126
- bin/theamify.js # npm bin β†’ src/cli.js
127
- src/cli.js # command router + signal safety net
128
- src/core/engine.js # runtime provisioning & engine forwarding
129
- src/lib/conf.js # themes.conf registry parser
130
- src/commands/manage.js # doctor / status / uninstall
131
- src/wizard/browse.js # interactive browser + chafa preview + apply
132
- vendor/ # bundled bash engine (theamify + lib/ + config/)
133
- ```
134
-
135
- ---
136
-
137
- ## πŸ—‘ Uninstalling
138
-
139
- ```bash
140
- theamify uninstall # interactive β€” removes the engine runtime
141
- npm uninstall -g theamify-cli
142
- ```
143
-
144
- Your active GRUB theme and `/etc/default/grub` settings are always preserved.
145
-
146
- ---
147
-
148
144
  ## πŸ“„ License
149
145
 
150
146
  MIT β€” see [LICENSE](LICENSE). Don Artkins 2026.
151
-
152
- ## Project Structure
153
- ```bash
154
- theamify/
155
-
156
- |-- theamify # Main CLI executable
157
-
158
- |-- install.sh # System-wide installer
159
-
160
- |-- uninstall.sh # Remover
161
-
162
- |-- update.sh # Self-updater (git pull + reinstall)
163
-
164
- |-- lib/
165
-
166
- | |-- colors.sh # ANSI colors & UI components
167
-
168
- | |-- utils.sh # Helpers, git clone, dep checks
169
-
170
- | |-- grub.sh # GRUB detection & apply logic
171
-
172
- | `-- themes.sh # Theme registry CRUD & download
173
-
174
- |-- config/
175
-
176
- | `-- themes.conf # Theme registry database
177
-
178
- |-- themes/ # Downloaded theme cache (auto-populated)
179
-
180
- |-- .repo_cache/ # Git clone cache (auto-populated)
181
-
182
- |-- .gitignore
183
-
184
- |-- LICENSE # MIT
185
-
186
- `-- CONTRIBUTING.md
187
- ```
188
-
189
- ## Install
190
-
191
- ```bash
192
- git clone https://github.com/DonArtkins/theamify
193
- cd theamify
194
- sudo ./install.sh
195
- ```
196
-
197
- After install, `theamify` is available system-wide - run it from any terminal.
198
- Only `theamify use` requires `sudo`. All other commands run as a normal user.
199
-
200
- ### Updating an existing install
201
-
202
- `install.sh` deliberately leaves an already-installed `config/themes.conf`
203
- alone, so your own `add`/`del` edits via `theamify` survive a reinstall.
204
- That also means a plain `sudo ./install.sh` will **not** pick up registry
205
- changes from this checkout (new/removed/edited entries) - pass `--sync-conf`
206
- when you want that:
207
-
208
- ```bash
209
- sudo ./install.sh --sync-conf
210
- ```
211
-
212
- See [CONTRIBUTING.md](CONTRIBUTING.md#developing-against-an-existing-install)
213
- for the full dev-vs-installed workflow.
214
-
215
- ## Usage
216
-
217
- ```bash
218
- theamify # Interactive TUI menu
219
- theamify list # List all themes with status
220
- theamify info <name> # Show details + preview (needs chafa)
221
- theamify get <name> # Download & cache a theme
222
- theamify get --all # Download every theme
223
- sudo theamify use <name> # Apply theme to GRUB + rebuild
224
- theamify update <name> # Re-download one theme
225
- theamify update # Re-download all cached themes
226
- theamify open <name> # Open source page in browser
227
- theamify add <github-url> # Add new theme to registry
228
- theamify del <name> # Remove theme from registry
229
- theamify remove <name> # Clear local cache (keeps registry entry)
230
- theamify status # Show GRUB + dependency status
231
- theamify clean # Clear repo clone cache
232
- ```
233
-
234
- ## Theme Previews in Terminal
235
-
236
- Install `chafa` for inline image previews in `theamify info`:
237
-
238
- ```bash
239
- sudo apt install chafa
240
- ```
241
-
242
- ## Themes Included
243
-
244
- | Name | Style | Source |
245
- |------------------|-----------------|--------|
246
- | CyberEXS | Cyberpunk dark | [gnome-look](https://www.gnome-look.org/p/1968990) |
247
- | CyberSynchro | Cyberpunk teal | [gnome-look](https://www.gnome-look.org/p/1972621) |
248
- | Space-Isolation | Sci-Fi | [gnome-look](https://www.gnome-look.org/p/2296342) |
249
- | Kawaii-GRUB | Anime | [gnome-look](https://www.gnome-look.org/p/2218890) |
250
- | Kayoko-Onikata | Anime / Cat | [gnome-look](https://www.gnome-look.org/p/2350900) |
251
- | Matrices | Abstract tech | [gnome-look](https://www.gnome-look.org/p/2271298) |
252
- | Particle | Abstract | [gnome-look](https://www.gnome-look.org/p/2269763) |
253
- | Zzz-GRUB | Chill / Cat | [gnome-look](https://www.gnome-look.org/p/2354136) |
254
-
255
- > **Matrices / Particle note:** both repos ship no pre-built theme folder -
256
- > `backgrounds/`, `common/`, and `config/` are raw assets, and `theme.txt` is
257
- > only produced at build time by their `generate.sh` (`-t [window|sidebar]
258
- > -s [1080p|2k|4k]`). The registry entries use a `generate:<args>` SUBDIR so
259
- > `theamify get` drives that script automatically - see CONTRIBUTING.md for
260
- > how that mechanism works if you want to add another theme shaped like this.
261
-
262
- ## Add Your Own Theme
263
-
264
- ```bash
265
- theamify add https://github.com/someone/their-grub-theme
266
- ```
267
-
268
- Follow the interactive wizard - it asks for name, subdir, description, and tags,
269
- then appends the entry to `themes.conf`.
270
-
271
- ## Uninstall
272
-
273
- ```bash
274
- sudo ./uninstall.sh
275
- ```
276
-
277
- Your active GRUB theme and `/etc/default/grub` settings are preserved.
278
-
279
- ## Contributing
280
-
281
- See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, coding conventions, and how
282
- to submit a theme or a patch.
283
-
284
- ## License
285
-
286
- MIT - see [LICENSE](LICENSE). Don Artkins 2026.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theamify-cli",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "theamify β€” GRUB theme manager & interactive browser wizard. Browse, preview, download and apply GRUB boot themes from the terminal.",
5
5
  "type": "module",
6
6
  "main": "src/cli.js",
@@ -49,4 +49,4 @@
49
49
  "execa": "^8.0.1",
50
50
  "picocolors": "^1.1.1"
51
51
  }
52
- }
52
+ }
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@ import pc from 'picocolors';
3
3
  import { createRequire } from 'node:module';
4
4
  import { runThemeBrowser } from './wizard/browse.js';
5
5
  import { runDoctor, runStatus, runUninstallWizard } from './commands/manage.js';
6
- import { updateChafa } from './lib/chafa.js';
6
+ import { updateManagedTools } from './lib/tools.js';
7
7
  import { runEngine } from './core/engine.js';
8
8
 
9
9
  const require = createRequire(import.meta.url);
@@ -85,9 +85,9 @@ const main = defineCommand({
85
85
  case 'uninstall':
86
86
  return runUninstallWizard();
87
87
  case 'update':
88
- // Self-update path: refresh the thumbnail renderer, then forward theme
89
- // re-downloads to the engine (no name β†’ all cached themes).
90
- await updateChafa();
88
+ // Self-update path: refresh companion tools (chafa, grub-customizer),
89
+ // then forward theme re-downloads to the engine.
90
+ await updateManagedTools();
91
91
  return forwardEngine(['update', ...rest]);
92
92
  case 'use':
93
93
  case 'apply':
@@ -2,15 +2,15 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import * as p from '@clack/prompts';
4
4
  import pc from 'picocolors';
5
- import { execa } from 'execa';
5
+ import { execa, execaSync } from 'execa';
6
6
  import {
7
7
  findInstalledRuntime,
8
8
  USER_DIR,
9
9
  USER_BIN_LINK,
10
10
  resolveEngine,
11
11
  } from '../core/engine.js';
12
- import { parseThemes } from '../lib/conf.js';
13
- import { uninstallChafa } from '../lib/chafa.js';
12
+ import { parseThemes, resolveConfPath } from '../lib/conf.js';
13
+ import { companionToolStatus } from '../lib/tools.js';
14
14
 
15
15
  /** `theamify doctor` β€” installation, GRUB and dependency health. */
16
16
  export async function runDoctor() {
@@ -23,29 +23,34 @@ export async function runDoctor() {
23
23
 
24
24
  // Registry / theme count
25
25
  try {
26
- const themes = parseThemes();
26
+ const themes = parseThemes(resolveConfPath());
27
27
  const cached = themes.filter((t) => fs.existsSync(path.join(USER_DIR, 'themes', t.name)));
28
28
  console.log(` Registry : ${themes.length} theme(s), ${cached.length} cached`);
29
29
  } catch (e) {
30
30
  console.log(` Registry : ${pc.red(`unreadable (${e.message})`)}`);
31
31
  }
32
32
 
33
+ // Base dependencies
34
+ const deps = ['git', 'wget', 'curl'].map((d) => [d, which(d)]);
35
+ console.log(' Deps : ' + deps.map(([d, ok]) => `${d}:${ok ? pc.green('βœ“') : pc.dim('β€”')}`).join(' '));
36
+
37
+ // Companion tools (chafa, grub-customizer)
38
+ const tools = companionToolStatus();
39
+ console.log(' Tools : ' + tools.map((t) => `${t.name}:${t.present ? pc.green('βœ“') : pc.dim('β€”')}`).join(' '));
40
+
33
41
  // GRUB active theme + dir
34
42
  const grubDir = grubDetectDir();
35
43
  console.log(` GRUB dir : ${pc.cyan(grubDir)}`);
36
44
  const active = activeThemeLabel();
37
45
  console.log(` Active : ${active ? pc.green('⭐ ' + active) : pc.dim('none')}`);
38
46
 
39
- // Dependencies
40
- const deps = ['git', 'chafa', 'wget', 'curl'].map((d) => [d, which(d)]);
41
- console.log(' Deps : ' + deps.map(([d, ok]) => `${d}:${ok ? pc.green('βœ“') : pc.dim('β€”')}`).join(' '));
42
47
  const grub = ['update-grub', 'grub-mkconfig', 'grub2-mkconfig'].filter((c) => which(c));
43
48
  console.log(` GRUB cmd : ${grub.length ? grub.join(', ') : pc.red('none found')}`);
44
49
  console.log();
45
50
  }
46
51
 
47
52
  function which(bin) {
48
- try { return execa.sync('which', [bin], { reject: false }).stdout.trim(); } catch { return ''; }
53
+ try { return execaSync('which', [bin], { reject: false }).stdout.trim(); } catch { return ''; }
49
54
  }
50
55
 
51
56
  function grubDetectDir() {
@@ -113,10 +118,10 @@ export async function runUninstallWizard() {
113
118
  });
114
119
  if (p.isCancel(keepGrub)) { p.cancel('Aborted.'); process.exit(0); }
115
120
 
116
- // Optionally remove the thumbnail renderer the wizard installed.
117
- await uninstallChafa();
121
+ // Companion tools (chafa, grub-customizer) are intentionally LEFT in place β€”
122
+ // the user may want them for later; uninstall only removes theamify itself.
118
123
 
119
124
  p.outro(pc.green(
120
- `Uninstalled.${removed ? ' Runtime removed.' : ''}${keepGrub ? ' Active GRUB theme left in place.' : ' To revert GRUB, remove GRUB_THEME= from /etc/default/grub and rebuild.'}`,
125
+ `Uninstalled.${removed ? ' Runtime removed.' : ''}${keepGrub ? ' Active GRUB theme left in place.' : ' To revert GRUB, remove GRUB_THEME= from /etc/default/grub and rebuild.'} Companion tools (chafa, grub-customizer) were kept for your use.`,
121
126
  ));
122
127
  }
package/src/lib/tools.js CHANGED
@@ -67,10 +67,121 @@ function packageNameFor(tool, pm) {
67
67
  }
68
68
 
69
69
  /** @returns {boolean} whether the tool binary is on PATH (sync) */
70
- function hasToolSync(tool) {
70
+ export function hasToolSync(tool) {
71
71
  try {
72
72
  return Boolean(execaSync('bash', ['-c', `command -v ${tool.bin}`], { reject: false }).stdout.trim());
73
73
  } catch {
74
74
  return false;
75
75
  }
76
+ }
77
+
78
+ /**
79
+ * Install a tool via sudo, prompting the user first. Returns true when the tool
80
+ * is usable afterwards.
81
+ * @param {{name:string,bin:string,purpose:string,install?:Object}} tool
82
+ * @param {{silent?:boolean}} [opts]
83
+ */
84
+ export async function ensureTool(tool, { silent = false } = {}) {
85
+ if (await hasTool(tool)) return true;
86
+
87
+ const pm = detectPackageManager();
88
+ const pkg = packageNameFor(tool, pm);
89
+ if (!pm || !pkg) {
90
+ if (!silent) p.log.warn(`No supported package manager (or no package) found for ${tool.name} β€” install "${tool.bin}" manually.`);
91
+ return false;
92
+ }
93
+
94
+ if (!silent) {
95
+ const want = await p.confirm({
96
+ message: `Install ${pc.cyan(tool.name)} (${pkg}) for ${tool.purpose}?`,
97
+ initialValue: true,
98
+ });
99
+ if (p.isCancel(want) || !want) {
100
+ p.log.message(pc.dim(`Skipped ${tool.name} β€” install later with your package manager.`));
101
+ return false;
102
+ }
103
+ }
104
+
105
+ const s = p.spinner();
106
+ s.start(`Installing ${tool.name}…`);
107
+ try {
108
+ // Release the spinner so the sudo prompt is visible & interruptible.
109
+ s.stop('');
110
+ const args = pm === 'pacman' ? ['-S', '--noconfirm', pkg] : [pm, 'install', '-y', pkg];
111
+ await runPrivileged(args);
112
+ if (!(await hasTool(tool))) throw new Error(`Install appeared to complete, but "${tool.bin}" is not on PATH.`);
113
+ if (!silent) p.log.success(`${tool.name} installed.`);
114
+ return true;
115
+ } catch (e) {
116
+ if (!silent) p.log.warn(`${tool.name} install failed: ${e.message}`);
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * If a tool is present, offer to update it (default: just continue). If missing,
123
+ * install it. This is the wizard's "auto-detect β†’ install or update β†’ continue".
124
+ */
125
+ export async function ensureManagedTool(tool) {
126
+ if (await hasTool(tool)) {
127
+ const decision = await p.confirm({
128
+ message: `${pc.cyan(tool.name)} is already installed. Update it now? (or just continue)`,
129
+ initialValue: false,
130
+ });
131
+ if (p.isCancel(decision)) return true;
132
+ if (decision) return updateTool(tool, { prompt: false });
133
+ p.log.message(pc.dim(`${tool.name} kept as-is β€” continuing.`));
134
+ return true;
135
+ }
136
+ return ensureTool(tool, { silent: false });
137
+ }
138
+
139
+ /** Run the wizard through every companion tool in registry order. */
140
+ export async function ensureManagedTools() {
141
+ p.note('Companion tools are detected, installed or updated first.', 'Setup');
142
+ for (const tool of TOOLS) {
143
+ await ensureManagedTool(tool);
144
+ }
145
+ }
146
+
147
+ /** Upgrade all managed companion tools (used by `theamify update`). */
148
+ export async function updateManagedTools() {
149
+ for (const tool of TOOLS) {
150
+ if (await hasTool(tool)) await updateTool(tool, { prompt: false });
151
+ else await ensureTool(tool, { silent: true });
152
+ }
153
+ }
154
+
155
+ /** Update a single tool to its latest available version (best-effort). */
156
+ export async function updateTool(tool, { prompt = false } = {}) {
157
+ if (!(await hasTool(tool))) return ensureTool(tool, { silent: true });
158
+
159
+ const pm = detectPackageManager();
160
+ const pkg = packageNameFor(tool, pm);
161
+ if (!pm || !pkg) return false;
162
+
163
+ if (prompt) {
164
+ const want = await p.confirm({ message: `Update ${pc.cyan(tool.name)} now?`, initialValue: true });
165
+ if (p.isCancel(want) || !want) { p.log.message(pc.dim(`${tool.name} update skipped.`)); return true; }
166
+ }
167
+
168
+ p.log.step(`Updating ${tool.name}…`);
169
+ try {
170
+ const args = pm === 'pacman'
171
+ ? ['-Syu', '--noconfirm', pkg]
172
+ : pm === 'apk'
173
+ ? ['apk', 'upgrade', pkg]
174
+ : [pm, 'install', '-y', '--only-upgrade', pkg];
175
+ await runPrivileged(args);
176
+ p.log.success(`${tool.name} updated.`);
177
+ return true;
178
+ } catch (e) {
179
+ p.log.warn(`${tool.name} update failed: ${e.message}`);
180
+ return false;
181
+ }
182
+ }
183
+
184
+ /** Report companion-tool presence (sync) for `doctor`. */
185
+ export function companionToolStatus() {
186
+ return TOOLS.map((tool) => ({ name: tool.name, present: hasToolSync(tool) }));
76
187
  }
@@ -2,8 +2,9 @@ import * as p from '@clack/prompts';
2
2
  import pc from 'picocolors';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { execa } from 'execa';
5
6
  import { parseThemes, resolveConfPath } from '../lib/conf.js';
6
- import { ensureChafa } from '../lib/chafa.js';
7
+ import { ensureManagedTools } from '../lib/tools.js';
7
8
  import { resolveEngine, USER_DIR } from '../core/engine.js';
8
9
 
9
10
  const USER_THEMES = path.join(USER_DIR, 'themes');
@@ -35,6 +36,11 @@ const statusColor = (status) => ({
35
36
  remote: pc.dim,
36
37
  }[status] || pc.dim);
37
38
 
39
+ /** True when a theme has been downloaded into the local user cache. */
40
+ function isCached(name) {
41
+ return fs.existsSync(path.join(USER_THEMES, name));
42
+ }
43
+
38
44
  /**
39
45
  * Render a terminal "thumbnail" preview of a cached theme via chafa (or any
40
46
  * terminal image renderer the user has). Silently nops when unsatisfiable.
@@ -50,7 +56,6 @@ export async function showThemePreview({ name, width = 40, height = 12 }) {
50
56
  .sort()[0];
51
57
  if (!image) return false;
52
58
 
53
- const { execa } = await import('execa');
54
59
  for (const tool of ['chafa', 'timg', 'viu', 'jp2a']) {
55
60
  const args = tool === 'chafa'
56
61
  ? ['--size', `${width}x${height}`, image]
@@ -63,103 +68,130 @@ export async function showThemePreview({ name, width = 40, height = 12 }) {
63
68
  }
64
69
  } catch { /* try next */ }
65
70
  }
66
- p.log.message(pc.dim('Install `chafa` (: sudo apt install chafa) for terminal theme previews.'));
71
+ p.log.message(pc.dim('Install `chafa` (sudo apt install chafa) for terminal theme previews.'));
67
72
  return false;
68
73
  }
69
74
 
70
- /** Interactive GRUB-theme browser wizard (mirrors GitSwitch's look & feel). */
71
- export async function runThemeBrowser() {
72
- // Step 1: ensure the thumbnail renderer is present before anything else.
73
- await ensureChafa();
74
-
75
- const themes = parseThemes(resolveConfPath());
76
- const active = activeTheme();
77
-
78
- const selected = await p.select({
79
- message: pc.bold('Pick a GRUB theme'),
80
- options: themes.map((t) => {
81
- const status = themeStatus(t.name);
82
- const color = statusColor(status);
83
- return {
84
- value: t,
85
- label: `${t.name} ${color(`[${status.toUpperCase()}]`)}${t.name === active ? ' ⭐' : ''}`,
86
- hint: t.tags.slice(0, 3).join(', '),
87
- };
88
- }),
89
- });
90
- if (p.isCancel(selected)) { p.cancel('Bye! πŸ‘‹'); process.exit(0); }
91
-
92
- p.log.step(`${pc.bold(selected.name)} β€” ${selected.desc}`);
93
- p.log.message(pc.dim(selected.url));
94
-
95
- if (fs.existsSync(path.join(USER_THEMES, selected.name))) {
96
- await showThemePreview({ name: selected.name });
75
+ /**
76
+ * Make sure a theme is present in the local cache, downloading on demand.
77
+ * @returns {Promise<boolean>} true when the theme is cached (ready to preview/apply)
78
+ */
79
+ async function ensureCached(engine, name) {
80
+ if (isCached(name)) return true;
81
+ p.log.warn(`${name} isn't downloaded yet. Fetching first…`);
82
+ const s = p.spinner();
83
+ s.start(`Downloading ${name}…`);
84
+ try {
85
+ await execa('bash', [engine, 'get', name], { stdio: 'inherit', reject: true });
86
+ s.stop(pc.green(`Downloaded ${name}.`));
87
+ return true;
88
+ } catch (e) {
89
+ s.stop(pc.red(`Download failed: ${e.message}`));
90
+ return false;
97
91
  }
92
+ }
98
93
 
99
- const action = await p.select({
100
- message: `What do you want to do with ${pc.cyan(selected.name)}?`,
101
- options: [
102
- { value: 'preview', label: 'Show terminal thumbnail preview', hint: 'requires chafa + cached theme' },
103
- { value: 'get', label: 'Download / update it', hint: 'git clone into local cache' },
104
- { value: 'use', label: 'Apply to GRUB', hint: 'needs sudo; rebuilds GRUB' },
105
- { value: 'open', label: 'Open source page in browser' },
106
- { value: 'back', label: 'Cancel' },
107
- ],
108
- });
109
- if (p.isCancel(action) || action === 'back') { p.outro('Bye! πŸ‘‹'); process.exit(0); }
94
+ /**
95
+ * Interactive GRUB-theme browser wizard (mirrors GitSwitch's look & feel).
96
+ * Stays in a menu loop: pick a theme β†’ preview / download / apply / open β†’
97
+ * back to the theme list β€” it NEVER hard-exits until you choose Quit.
98
+ */
99
+ export async function runThemeBrowser() {
100
+ // Step 1: ensure companion tools (chafa, grub-customizer) β€” detect, install
101
+ // or update, then continue BEFORE showing the theme picker.
102
+ await ensureManagedTools();
110
103
 
111
104
  const engine = await resolveEngine();
112
- const { execa } = await import('execa');
113
-
114
- if (action === 'preview') {
115
- await showThemePreview({ name: selected.name, width: 80, height: 20 });
116
- p.outro(pc.green('Preview above.'));
117
- } else if (action === 'get') {
118
- const s = p.spinner();
119
- s.start(`Downloading ${selected.name}…`);
120
- try {
121
- await execa('bash', [engine, 'get', selected.name], { stdio: 'inherit', reject: true });
122
- s.stop(pc.green(`Downloaded ${selected.name}.`));
123
- } catch (e) {
124
- s.stop(pc.red(`Download failed: ${e.message}`));
105
+ let quit = false;
106
+
107
+ while (!quit) {
108
+ const themes = parseThemes(resolveConfPath());
109
+ const active = activeTheme();
110
+
111
+ const picked = await p.select({
112
+ message: pc.bold('Pick a GRUB theme'),
113
+ options: themes.map((t) => {
114
+ const status = themeStatus(t.name);
115
+ const color = statusColor(status);
116
+ return {
117
+ value: t,
118
+ label: `${t.name} ${color(`[${status.toUpperCase()}]`)}${t.name === active ? ' ⭐' : ''}`,
119
+ hint: (isCached(t.name) ? 'πŸ–Ό ' : '') + t.tags.slice(0, 3).join(', '),
120
+ };
121
+ }),
122
+ });
123
+ if (p.isCancel(picked)) { quit = true; break; }
124
+
125
+ p.log.step(`${pc.bold(picked.name)} β€” ${picked.desc}`);
126
+ p.log.message(pc.dim(picked.url));
127
+
128
+ // Thumbnails render by default: show the preview whenever the theme is
129
+ // cached, otherwise point the user at Download to unlock it.
130
+ const cached = isCached(picked.name);
131
+ if (cached) {
132
+ await showThemePreview({ name: picked.name });
133
+ } else {
134
+ p.log.message(pc.dim('Not cached yet β€” choose β€œDownload / update it” below to preview it.'));
135
+ }
136
+
137
+ // Per-theme action loop: runs until the user goes back to the list or quits.
138
+ let backToMenu = false;
139
+ while (!quit && !backToMenu) {
140
+ const action = await p.select({
141
+ message: `What do you want to do with ${pc.cyan(picked.name)}?`,
142
+ options: [
143
+ { value: 'preview', label: 'Preview terminal thumbnail', hint: cached ? 'renders below' : 'downloads first' },
144
+ { value: 'get', label: 'Download / update it', hint: 'git clone into local cache' },
145
+ { value: 'use', label: 'Apply to GRUB', hint: 'needs sudo; rebuilds GRUB' },
146
+ { value: 'open', label: 'Open source page in browser' },
147
+ { value: 'menu', label: '← Back to theme list' },
148
+ { value: 'quit', label: 'Quit browser' },
149
+ ],
150
+ });
151
+
152
+ if (p.isCancel(action) || action === 'quit') { quit = true; break; }
153
+ if (action === 'menu') { backToMenu = true; break; }
154
+
155
+ if (action === 'preview') {
156
+ if (await ensureCached(engine, picked.name)) {
157
+ await showThemePreview({ name: picked.name, width: 80, height: 22 });
158
+ }
159
+ } else if (action === 'get') {
160
+ await ensureCached(engine, picked.name);
161
+ } else if (action === 'use') {
162
+ await runUse(engine, picked.name);
163
+ } else if (action === 'open') {
164
+ await execa('bash', [engine, 'open', picked.name], { stdio: 'inherit', reject: false });
165
+ }
125
166
  }
126
- } else if (action === 'use') {
127
- await runUse(engine, selected.name);
128
- } else if (action === 'open') {
129
- await execa('bash', [engine, 'open', selected.name], { stdio: 'inherit', reject: false });
130
167
  }
168
+
169
+ p.outro(pc.cyan('Bye! πŸ‘‹'));
131
170
  }
132
171
 
133
- /** Apply a theme to GRUB, re-invoking with sudo, spinner released for the prompt. */
172
+ /**
173
+ * Apply a theme to GRUB, re-invoking with sudo, spinner released for the prompt.
174
+ * Returns true when the apply was attempted/succeeded (false if aborted).
175
+ */
134
176
  export async function runUse(engine, name) {
135
- const cached = path.join(USER_THEMES, name);
136
- if (!fs.existsSync(cached)) {
137
- p.log.warn(`${name} isn't downloaded yet. Fetching first…`);
138
- const s = p.spinner();
139
- s.start(`Downloading ${name}…`);
140
- try {
141
- await execa('bash', [engine, 'get', name], { stdio: 'inherit', reject: true });
142
- s.stop();
143
- } catch (e) {
144
- s.stop(pc.red(`Download failed: ${e.message}`));
145
- return;
146
- }
147
- }
177
+ if (!(await ensureCached(engine, name))) { p.cancel('Aborted β€” theme not downloaded.'); return false; }
148
178
 
149
179
  const confirmApply = await p.confirm({ message: `Apply ${pc.cyan(name)} to GRUB and rebuild? (sudo)`, initialValue: true });
150
- if (p.isCancel(confirmApply) || !confirmApply) { p.outro('Skipped.'); return; }
180
+ if (p.isCancel(confirmApply) || !confirmApply) {
181
+ p.log.message(pc.dim('Skipped β€” theme not applied.'));
182
+ return false;
183
+ }
151
184
 
152
- const { execa } = await import('execa');
153
185
  if (process.getuid() !== 0) {
154
186
  // Release spinner, then prompt sudo on a clean terminal so Ctrl+C works.
155
187
  console.log();
156
188
  const res = await execa('sudo', ['bash', engine, 'use', name], { stdio: 'inherit', reject: false });
157
- return res.exitCode === 0
158
- ? p.outro(pc.green(`${name} is now your GRUB theme! πŸŽ‰`))
159
- : p.cancel('GRUB apply failed.');
189
+ if (res.exitCode === 0) { p.log.success(`${name} is now your GRUB theme! πŸŽ‰`); return true; }
190
+ p.cancel('GRUB apply failed.');
191
+ return false;
160
192
  }
161
193
  const res = await execa('bash', [engine, 'use', name], { stdio: 'inherit', reject: false });
162
- res.exitCode === 0
163
- ? p.outro(pc.green(`${name} is now your GRUB theme! πŸŽ‰`))
164
- : p.cancel('GRUB apply failed.');
165
- }
194
+ if (res.exitCode === 0) { p.log.success(`${name} is now your GRUB theme! πŸŽ‰`); return true; }
195
+ p.cancel('GRUB apply failed.');
196
+ return false;
197
+ }
package/src/lib/chafa.js DELETED
@@ -1,143 +0,0 @@
1
- import { execa, execaSync } from 'execa';
2
- import pc from 'picocolors';
3
- import * as p from '@clack/prompts';
4
-
5
- /**
6
- * chafa β€” the terminal image renderer behind theamify's thumbnail previews.
7
- * This module manages chafa as a first-class wizard dependency:
8
- * - install when the wizard runs and chafa is missing
9
- * - update when the wizard updates itself
10
- * - uninstall when the wizard is uninstalled (opt-in)
11
- */
12
-
13
- export const CHAFA_BIN = 'chafa';
14
-
15
- /** @returns {Promise<boolean>} true if `chafa` is on PATH. */
16
- export async function hasChafa() {
17
- try {
18
- const res = await execa('bash', ['-c', `command -v ${CHAFA_BIN}`], { reject: false });
19
- return Boolean(res.stdout.trim());
20
- } catch {
21
- return false;
22
- }
23
- }
24
-
25
- /** Detect which package manager is available. @returns {string|null} */
26
- export function detectPackageManager() {
27
- const order = ['apt-get', 'dnf', 'yum', 'pacman', 'zypper', 'apk'];
28
- const pm = order.find((name) => {
29
- try {
30
- return Boolean(execaSync('bash', ['-c', `command -v ${name}`], { reject: false }).stdout.trim());
31
- } catch {
32
- return false;
33
- }
34
- });
35
- return pm || null;
36
- }
37
-
38
- /**
39
- * Run a privileged package-manager command, releasing the terminal first so the
40
- * sudo password prompt is visible and Ctrl+C actually works. Mirrors the
41
- * interactive-sudo pattern used by gitswitch and warp-wizard.
42
- * @param {string[]} cmd sudo + args
43
- */
44
- async function runPrivileged(cmd) {
45
- const res = await execa('sudo', cmd, { stdio: 'inherit', reject: false });
46
- if (res.exitCode !== 0) throw new Error(`Command failed: sudo ${cmd.join(' ')} (exit ${res.exitCode})`);
47
- return res;
48
- }
49
-
50
- /**
51
- * Install chafa, prompting the user first. Safe no-op when a package manager is
52
- * unavailable or chafa is already installed.
53
- * @param {{silent?: boolean}} [opts]
54
- * @returns {Promise<boolean>} true when chafa is available afterwards
55
- */
56
- export async function ensureChafa({ silent = false } = {}) {
57
- if (await hasChafa()) return true;
58
-
59
- const pm = detectPackageManager();
60
- if (!pm) {
61
- if (!silent) p.log.warn('No supported package manager found β€” install chafa manually to enable previews.');
62
- return false;
63
- }
64
-
65
- const want = silent ? true : await p.confirm({
66
- message: `Install ${pc.cyan('chafa')} to enable terminal theme previews?`,
67
- initialValue: true,
68
- });
69
- if (p.isCancel(want) || !want) {
70
- if (!silent) p.log.message(pc.dim('Skipped β€” theme thumbnails will be disabled. Install later with your package manager.'));
71
- return false;
72
- }
73
-
74
- if (!silent) p.log.step(`Installing chafa via ${pm}…`);
75
- const s = p.spinner();
76
- s.start('Installing chafa…');
77
- try {
78
- // Release the spinner so the sudo prompt is visible & interruptible.
79
- s.stop('');
80
- const pkgArgs = pm === 'pacman' ? ['-S', '--noconfirm', 'chafa'] : [pm, 'install', '-y', 'chafa'];
81
- await runPrivileged(pkgArgs);
82
- if (!(await hasChafa())) throw new Error('chafa install appeared to complete, but chafa is not on PATH.');
83
- if (!silent) p.log.success('chafa installed β€” terminal thumbnails enabled!');
84
- return true;
85
- } catch (e) {
86
- if (!silent) p.log.warn(`chafa install failed: ${e.message}\n Try manually: sudo apt install chafa`);
87
- return false;
88
- }
89
- }
90
-
91
- /** Update chafa to its latest available version (best-effort). */
92
- export async function updateChafa() {
93
- if (!(await hasChafa())) return ensureChafa({ silent: true });
94
-
95
- const pm = detectPackageManager();
96
- if (!pm) return false;
97
- p.log.step('Updating chafa…');
98
- try {
99
- const args = pm === 'pacman'
100
- ? ['-Syu', '--noconfirm', 'chafa']
101
- : pm === 'apk'
102
- ? ['apk', 'upgrade', 'chafa']
103
- : [pm, 'install', '-y', '--only-upgrade', 'chafa'];
104
- await runPrivileged(args);
105
- p.log.success('chafa updated.');
106
- return true;
107
- } catch (e) {
108
- p.log.warn(`chafa update failed: ${e.message}`);
109
- return false;
110
- }
111
- }
112
-
113
- /**
114
- * Remove chafa after confirming. Only called from the uninstall wizard.
115
- * @returns {Promise<boolean>} true when chafa was removed
116
- */
117
- export async function uninstallChafa() {
118
- if (!(await hasChafa())) { p.log.message(pc.dim('chafa not installed β€” nothing to remove.')); return false; }
119
-
120
- const pm = detectPackageManager();
121
- if (!pm) { p.log.warn('No package manager found β€” remove chafa yourself.'); return false; }
122
-
123
- const want = await p.confirm({
124
- message: 'Also remove chafa (thumbnail renderer)? You can re-enable previews later by reinstalling it.',
125
- initialValue: false,
126
- });
127
- if (p.isCancel(want) || !want) { p.log.message(pc.dim('Kept chafa.')); return false; }
128
-
129
- p.log.step(`Removing chafa via ${pm}…`);
130
- try {
131
- const args = pm === 'pacman'
132
- ? ['pacman', '-Rns', '--noconfirm', 'chafa']
133
- : pm === 'apk'
134
- ? ['apk', 'del', 'chafa']
135
- : [pm, 'remove', '-y', 'chafa'];
136
- await runPrivileged(args);
137
- p.log.success('chafa removed.');
138
- return true;
139
- } catch (e) {
140
- p.log.warn(`chafa removal failed: ${e.message}`);
141
- return false;
142
- }
143
- }