theamify-cli 1.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theamify-cli",
3
- "version": "1.1.0",
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
+ }
@@ -2,7 +2,7 @@ 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,
@@ -50,7 +50,7 @@ export async function runDoctor() {
50
50
  }
51
51
 
52
52
  function which(bin) {
53
- try { return execa.sync('which', [bin], { reject: false }).stdout.trim(); } catch { return ''; }
53
+ try { return execaSync('which', [bin], { reject: false }).stdout.trim(); } catch { return ''; }
54
54
  }
55
55
 
56
56
  function grubDetectDir() {
@@ -2,6 +2,7 @@ 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
7
  import { ensureManagedTools } from '../lib/tools.js';
7
8
  import { resolveEngine, USER_DIR } from '../core/engine.js';
@@ -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,104 +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). */
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;
91
+ }
92
+ }
93
+
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
+ */
71
99
  export async function runThemeBrowser() {
72
100
  // Step 1: ensure companion tools (chafa, grub-customizer) — detect, install
73
101
  // or update, then continue BEFORE showing the theme picker.
74
102
  await ensureManagedTools();
75
103
 
76
- const themes = parseThemes(resolveConfPath());
77
- const active = activeTheme();
78
-
79
- const selected = await p.select({
80
- message: pc.bold('Pick a GRUB theme'),
81
- options: themes.map((t) => {
82
- const status = themeStatus(t.name);
83
- const color = statusColor(status);
84
- return {
85
- value: t,
86
- label: `${t.name} ${color(`[${status.toUpperCase()}]`)}${t.name === active ? ' ⭐' : ''}`,
87
- hint: t.tags.slice(0, 3).join(', '),
88
- };
89
- }),
90
- });
91
- if (p.isCancel(selected)) { p.cancel('Bye! 👋'); process.exit(0); }
92
-
93
- p.log.step(`${pc.bold(selected.name)} — ${selected.desc}`);
94
- p.log.message(pc.dim(selected.url));
95
-
96
- if (fs.existsSync(path.join(USER_THEMES, selected.name))) {
97
- await showThemePreview({ name: selected.name });
98
- }
99
-
100
- const action = await p.select({
101
- message: `What do you want to do with ${pc.cyan(selected.name)}?`,
102
- options: [
103
- { value: 'preview', label: 'Show terminal thumbnail preview', hint: 'requires chafa + cached theme' },
104
- { value: 'get', label: 'Download / update it', hint: 'git clone into local cache' },
105
- { value: 'use', label: 'Apply to GRUB', hint: 'needs sudo; rebuilds GRUB' },
106
- { value: 'open', label: 'Open source page in browser' },
107
- { value: 'back', label: 'Cancel' },
108
- ],
109
- });
110
- if (p.isCancel(action) || action === 'back') { p.outro('Bye! 👋'); process.exit(0); }
111
-
112
104
  const engine = await resolveEngine();
113
- const { execa } = await import('execa');
114
-
115
- if (action === 'preview') {
116
- await showThemePreview({ name: selected.name, width: 80, height: 20 });
117
- p.outro(pc.green('Preview above.'));
118
- } else if (action === 'get') {
119
- const s = p.spinner();
120
- s.start(`Downloading ${selected.name}…`);
121
- try {
122
- await execa('bash', [engine, 'get', selected.name], { stdio: 'inherit', reject: true });
123
- s.stop(pc.green(`Downloaded ${selected.name}.`));
124
- } catch (e) {
125
- 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
+ }
126
166
  }
127
- } else if (action === 'use') {
128
- await runUse(engine, selected.name);
129
- } else if (action === 'open') {
130
- await execa('bash', [engine, 'open', selected.name], { stdio: 'inherit', reject: false });
131
167
  }
168
+
169
+ p.outro(pc.cyan('Bye! 👋'));
132
170
  }
133
171
 
134
- /** 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
+ */
135
176
  export async function runUse(engine, name) {
136
- const cached = path.join(USER_THEMES, name);
137
- if (!fs.existsSync(cached)) {
138
- p.log.warn(`${name} isn't downloaded yet. Fetching first…`);
139
- const s = p.spinner();
140
- s.start(`Downloading ${name}…`);
141
- try {
142
- await execa('bash', [engine, 'get', name], { stdio: 'inherit', reject: true });
143
- s.stop();
144
- } catch (e) {
145
- s.stop(pc.red(`Download failed: ${e.message}`));
146
- return;
147
- }
148
- }
177
+ if (!(await ensureCached(engine, name))) { p.cancel('Aborted — theme not downloaded.'); return false; }
149
178
 
150
179
  const confirmApply = await p.confirm({ message: `Apply ${pc.cyan(name)} to GRUB and rebuild? (sudo)`, initialValue: true });
151
- 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
+ }
152
184
 
153
- const { execa } = await import('execa');
154
185
  if (process.getuid() !== 0) {
155
186
  // Release spinner, then prompt sudo on a clean terminal so Ctrl+C works.
156
187
  console.log();
157
188
  const res = await execa('sudo', ['bash', engine, 'use', name], { stdio: 'inherit', reject: false });
158
- return res.exitCode === 0
159
- ? p.outro(pc.green(`${name} is now your GRUB theme! 🎉`))
160
- : 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;
161
192
  }
162
193
  const res = await execa('bash', [engine, 'use', name], { stdio: 'inherit', reject: false });
163
- res.exitCode === 0
164
- ? p.outro(pc.green(`${name} is now your GRUB theme! 🎉`))
165
- : p.cancel('GRUB apply failed.');
166
- }
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
+ }