theamify-cli 2.0.0 → 2.2.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/bin/theamify.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theamify-cli",
3
- "version": "2.0.0",
3
+ "version": "2.2.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",
package/src/cli.js CHANGED
@@ -2,7 +2,7 @@ import { defineCommand, runMain } from 'citty';
2
2
  import pc from 'picocolors';
3
3
  import { createRequire } from 'node:module';
4
4
  import { runThemeBrowser } from './wizard/browse.js';
5
- import { runDoctor, runStatus, runUninstallWizard } from './commands/manage.js';
5
+ import { runDoctor, runStatus, runUninstallWizard, runSelfUpgrade, runRepair } from './commands/manage.js';
6
6
  import { updateManagedTools } from './lib/tools.js';
7
7
  import { runEngine } from './core/engine.js';
8
8
 
@@ -10,7 +10,7 @@ const require = createRequire(import.meta.url);
10
10
  const pkg = require('../package.json');
11
11
 
12
12
  /** Commands forwarded straight to the bash engine. */
13
- const ENGINE_COMMANDS = ['list', 'ls', 'info', 'show', 'get', 'fetch', 'download', 'remove', 'rm', 'uncache', 'add', 'del', 'delete', 'update', 'open', 'browse', 'clean', 'purge-cache', 'help', '-h', '-V', '--version'];
13
+ const ENGINE_COMMANDS = ['list', 'ls', 'info', 'show', 'get', 'fetch', 'download', 'remove', 'rm', 'uncache', 'add', 'del', 'delete', 'update', 'open', 'clean', 'purge-cache'];
14
14
 
15
15
  /**
16
16
  * Forward argv to the bash engine and mirror its exit status onto this process.
@@ -42,7 +42,9 @@ ${pc.bold('Usage:')}
42
42
  theamify open <name> Open the theme source page in a browser
43
43
  theamify status Show GRUB & dependency status
44
44
  theamify doctor Diagnose install, GRUB & dependencies
45
- theamify uninstall Remove theamify (asks before deleting anything)
45
+ theamify upgrade Check for & install the latest npm version
46
+ theamify repair Fix a broken install (re-provision the engine)
47
+ theamify uninstall Remove theamify entirely (incl. npm package)
46
48
  theamify clean Clear the repo clone cache
47
49
  `);
48
50
  }
@@ -78,10 +80,30 @@ const main = defineCommand({
78
80
  case 'wizard':
79
81
  case 'browse':
80
82
  return runThemeBrowser();
83
+ case 'install':
84
+ // Guided setup: companion tools → download all themes.
85
+ return runThemeBrowser();
86
+ case 'help':
87
+ case '-h':
88
+ case '--help':
89
+ printUsage();
90
+ return;
91
+ case 'version':
92
+ case '-v':
93
+ case '-V':
94
+ case '--version':
95
+ console.log(`theamify v${pkg.version}`);
96
+ return;
81
97
  case 'status':
82
98
  return runStatus();
83
99
  case 'doctor':
84
100
  return runDoctor();
101
+ case 'upgrade':
102
+ case 'self-update':
103
+ case 'selfupdate':
104
+ return runSelfUpgrade();
105
+ case 'repair':
106
+ return runRepair();
85
107
  case 'uninstall':
86
108
  return runUninstallWizard();
87
109
  case 'update':
@@ -6,11 +6,13 @@ import { execa, execaSync } from 'execa';
6
6
  import {
7
7
  findInstalledRuntime,
8
8
  USER_DIR,
9
- USER_BIN_LINK,
9
+ removeShadowBin,
10
+ repairRuntime,
10
11
  resolveEngine,
11
12
  } from '../core/engine.js';
12
13
  import { parseThemes, resolveConfPath } from '../lib/conf.js';
13
14
  import { companionToolStatus } from '../lib/tools.js';
15
+ import { checkForUpdate, promptSelfUpdate, selfUninstall } from '../lib/self.js';
14
16
 
15
17
  /** `theamify doctor` — installation, GRUB and dependency health. */
16
18
  export async function runDoctor() {
@@ -80,7 +82,85 @@ export async function runStatus() {
80
82
  if (res.exitCode !== 0) process.exit(res.exitCode);
81
83
  }
82
84
 
83
- /** `theamify uninstall` — interactive, every destructive step confirmed. */
85
+ /** `theamify upgrade` — self-update the npm CLI if a newer version is published. */
86
+ export async function runSelfUpgrade() {
87
+ const { outdated, latest, current } = await checkForUpdate();
88
+ if (!outdated) {
89
+ console.log(pc.green(`Already on the latest version (v${current}).`));
90
+ return;
91
+ }
92
+ console.log(`Available: ${pc.cyan('v' + latest)} (you have ${pc.dim('v' + current)})`);
93
+ await promptSelfUpdate();
94
+ }
95
+
96
+ /** `theamify repair` — fix a broken install by re-provisioning the engine/runtime. */
97
+ export async function runRepair() {
98
+ console.log(pc.bold('\n🔧 theamify Repair\n'));
99
+ const engine = await repairRuntime();
100
+ console.log(` Engine : ${pc.cyan(engine)}`);
101
+ console.log(` Runtime : ${pc.cyan(path.join(USER_DIR))}`);
102
+ console.log(' Status : ' + pc.green('engine re-provisioned'));
103
+ console.log(' Downloads: ' + (fs.existsSync(path.join(USER_DIR, 'themes')) ? pc.green('themes preserved') : pc.dim('no cached themes yet')));
104
+ console.log();
105
+ }
106
+
107
+ /**
108
+ * Remove a theamify runtime directory and everything in it (the engine PLUS all
109
+ * downloaded themes under themes/ and .repo_cache/). Uses sudo when the dir is
110
+ * root-owned.
111
+ * @param {string} dir absolute path to the runtime dir
112
+ * @returns {Promise<boolean>} true when the dir no longer exists
113
+ */
114
+ export async function removeRuntimeDir(dir) {
115
+ if (!fs.existsSync(dir)) return true;
116
+ try {
117
+ const owner = fs.statSync(dir).uid;
118
+ if (owner === 0 && process.getuid() !== 0) {
119
+ const res = await execa('sudo', ['rm', '-rf', dir], { stdio: 'inherit', reject: false });
120
+ return res.exitCode === 0 && !fs.existsSync(dir);
121
+ }
122
+ fs.rmSync(dir, { recursive: true, force: true });
123
+ return !fs.existsSync(dir);
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** Build the bash script that clears GRUB_THEME and rebuilds the boot menu. */
130
+ export function buildResetGrubScript(grubFile = '/etc/default/grub') {
131
+ return `
132
+ GRUB=${grubFile}
133
+ [ -f "$GRUB" ] || exit 0
134
+ sed -i '/^GRUB_THEME=/d' "$GRUB"
135
+ if command -v update-grub >/dev/null 2>&1; then
136
+ update-grub
137
+ elif command -v grub-mkconfig >/dev/null 2>&1; then
138
+ grub-mkconfig -o /boot/grub/grub.cfg
139
+ elif command -v grub2-mkconfig >/dev/null 2>&1; then
140
+ grub2-mkconfig -o /boot/grub2/grub.cfg
141
+ fi
142
+ `.trim();
143
+ }
144
+
145
+ /**
146
+ * Remove GRUB_THEME from /etc/default/grub and rebuild the boot menu so the
147
+ * system returns to the default theme. Requires sudo when not root.
148
+ * @param {{grubFile?:string, asRoot?:boolean}} [opts] injectable for tests
149
+ * @returns {Promise<boolean>} true when GRUB was reset
150
+ */
151
+ export async function resetGrubTheme({ grubFile = '/etc/default/grub', asRoot = process.getuid() === 0 } = {}) {
152
+ const script = buildResetGrubScript(grubFile);
153
+ if (asRoot) {
154
+ const res = await execa('bash', ['-c', script], { stdio: 'inherit', reject: false });
155
+ return res.exitCode === 0;
156
+ }
157
+ // Release the terminal so the sudo password prompt is visible & interruptible.
158
+ console.log();
159
+ const res = await execa('sudo', ['bash', '-c', script], { stdio: 'inherit', reject: false });
160
+ return res.exitCode === 0;
161
+ }
162
+
163
+ /** `theamify uninstall` — removes theamify, ALL downloaded themes, and resets GRUB to default. */
84
164
  export async function runUninstallWizard() {
85
165
  p.intro(pc.bgRed(pc.black(' theamify Uninstaller ')));
86
166
  const found = findInstalledRuntime();
@@ -89,39 +169,34 @@ export async function runUninstallWizard() {
89
169
  let removed = false;
90
170
  if (found) {
91
171
  const confirm = await p.confirm({
92
- message: `Remove theamify files at ${found.dir}/?`,
172
+ message: `Remove theamify files at ${found.dir}/? (this deletes ALL downloaded themes)`,
93
173
  initialValue: true,
94
174
  });
95
175
  if (!p.isCancel(confirm) && confirm) {
96
- try {
97
- const dir = found.dir;
98
- const owner = fs.statSync(dir).uid;
99
- if (owner === 0 && process.getuid() !== 0) {
100
- await execa('sudo', ['rm', '-rf', dir], { stdio: 'inherit' });
101
- } else {
102
- fs.rmSync(dir, { recursive: true, force: true });
103
- }
104
- removed = true;
105
- // remove the PATH symlink arm (never touches ~/.bashrc blocks without asking? leave PATH block — it's harmless)
106
- if (USER_BIN_LINK.startsWith(USER_DIR)) {
107
- fs.rmSync(USER_BIN_LINK, { force: true });
108
- }
109
- } catch {
110
- p.log.warn(`Could not remove ${found.dir}.`);
111
- }
176
+ removed = await removeRuntimeDir(found.dir);
177
+ if (removed) removeShadowBin();
178
+ else p.log.warn(`Could not remove ${found.dir}.`);
112
179
  }
113
180
  }
114
181
 
115
- const keepGrub = await p.confirm({
116
- message: 'Keep your currently-applied GRUB theme? (recommended)',
182
+ // Reset the boot menu back to the default (remove the applied GRUB theme).
183
+ const resetGrub = await p.confirm({
184
+ message: 'Remove your applied GRUB theme and reset to the default boot menu?',
117
185
  initialValue: true,
118
186
  });
119
- if (p.isCancel(keepGrub)) { p.cancel('Aborted.'); process.exit(0); }
187
+ if (p.isCancel(resetGrub)) { p.cancel('Aborted.'); process.exit(0); }
188
+ const grubReset = resetGrub ? await resetGrubTheme() : false;
189
+
190
+ // Remove the npm package so the `theamify` command actually disappears.
191
+ const npmRemoved = await selfUninstall();
120
192
 
121
193
  // Companion tools (chafa, grub-customizer) are intentionally LEFT in place —
122
194
  // the user may want them for later; uninstall only removes theamify itself.
195
+ const themeNote = grubReset
196
+ ? 'Boot menu reset to the default theme.'
197
+ : (resetGrub ? 'GRUB reset could not be completed — remove GRUB_THEME= from /etc/default/grub and rebuild.' : 'Your applied GRUB theme was left in place.');
123
198
 
124
199
  p.outro(pc.green(
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.`,
200
+ `Uninstalled.${removed ? ' Runtime + all downloaded themes removed.' : ''} ${themeNote} ${npmRemoved ? 'theamify npm package removed command no longer available.' : 'theamify npm package kept.'} Companion tools (chafa, grub-customizer) were kept for your use.`,
126
201
  ));
127
202
  }
@@ -14,6 +14,7 @@ export const ENGINE_SCRIPT = path.join(VENDOR_DIR, 'theamify');
14
14
  export const SYSTEM_DIR = '/usr/local/share/theamify';
15
15
  export const USER_DIR = path.join(os.homedir(), '.local', 'share', 'theamify');
16
16
  export const BIN_NAME = 'theamify';
17
+ /** Legacy self-shadowing symlink that used to point at the bundled bash engine. */
17
18
  export const USER_BIN_LINK = path.join(os.homedir(), '.local', 'bin', BIN_NAME);
18
19
 
19
20
  /**
@@ -27,51 +28,88 @@ export function findInstalledRuntime() {
27
28
  return null;
28
29
  }
29
30
 
31
+ /**
32
+ * Remove the legacy `~/.local/bin/theamify` symlink if it exists. It was created
33
+ * by old installs to point at the bundled bash engine, which SHADOWS the npm CLI
34
+ * (because `~/.local/bin` precedes the npm global bin on PATH — so `theamify`,
35
+ * `theamify uninstall`, `theamify doctor`, etc. silently ran the old engine).
36
+ * The npm package already installs `theamify -> bin/theamify.js` in the npm
37
+ * global bin, which is on PATH, so the symlink is unnecessary and harmful.
38
+ * Only ever removes a symlink — never a real file the user owns.
39
+ */
40
+ export function removeShadowBin() {
41
+ try {
42
+ const st = fs.lstatSync(USER_BIN_LINK);
43
+ if (st.isSymbolicLink()) fs.rmSync(USER_BIN_LINK, { force: true });
44
+ } catch { /* nothing to remove */ }
45
+ }
46
+
47
+ /** Copy the vendored engine script + shared libs into a runtime dir. */
48
+ function copyEngineTo(dir) {
49
+ fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });
50
+ fs.copyFileSync(path.join(VENDOR_DIR, BIN_NAME), path.join(dir, BIN_NAME));
51
+ fs.chmodSync(path.join(dir, BIN_NAME), 0o755);
52
+ for (const lib of ['colors', 'utils', 'grub', 'themes']) {
53
+ fs.copyFileSync(path.join(VENDOR_DIR, 'lib', `${lib}.sh`), path.join(dir, 'lib', `${lib}.sh`));
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Refreshes an existing installed runtime's engine script + libs to match the
59
+ * bundled package (so the disk engine is never a stale older version). User
60
+ * registry edits in config/themes.conf are preserved.
61
+ */
62
+ function syncEngineTo(dir) {
63
+ const installed = path.join(dir, BIN_NAME);
64
+ const vendored = path.join(VENDOR_DIR, BIN_NAME);
65
+ try {
66
+ if (fs.existsSync(installed) && fs.readFileSync(installed, 'utf8') !== fs.readFileSync(vendored, 'utf8')) {
67
+ copyEngineTo(dir);
68
+ }
69
+ } catch {
70
+ copyEngineTo(dir);
71
+ }
72
+ }
73
+
30
74
  /**
31
75
  * Provision a user-owned install of the engine under ~/.local/share/theamify so
32
76
  * it is writable without root (downloads write into themes/ and .repo_cache/).
33
- * Preserves an existing config/themes.conf (user registry edits). Returns the
77
+ * Preserves an existing config/themes.conf (user registry edits). Does NOT create
78
+ * a PATH shadow symlink — the npm global bin is already on PATH. Returns the
34
79
  * runtime dir.
35
80
  */
36
81
  export async function installUserRuntime() {
37
- const { execaSync } = await import('execa');
38
- fs.mkdirSync(path.join(USER_DIR, 'lib'), { recursive: true });
39
82
  fs.mkdirSync(path.join(USER_DIR, 'config'), { recursive: true });
40
83
  fs.mkdirSync(path.join(USER_DIR, 'themes'), { recursive: true });
41
84
  fs.mkdirSync(path.join(USER_DIR, '.repo_cache'), { recursive: true });
42
85
 
43
- fs.copyFileSync(path.join(VENDOR_DIR, BIN_NAME), path.join(USER_DIR, BIN_NAME));
44
- fs.chmodSync(path.join(USER_DIR, BIN_NAME), 0o755);
45
- for (const lib of ['colors', 'utils', 'grub', 'themes']) {
46
- fs.copyFileSync(path.join(VENDOR_DIR, 'lib', `${lib}.sh`), path.join(USER_DIR, 'lib', `${lib}.sh`));
47
- }
86
+ copyEngineTo(USER_DIR);
48
87
  const confSrc = path.join(VENDOR_DIR, 'config', 'themes.conf');
49
88
  const confDst = path.join(USER_DIR, 'config', 'themes.conf');
50
89
  if (!fs.existsSync(confDst)) {
51
90
  fs.copyFileSync(confSrc, confDst);
52
91
  }
53
92
 
54
- // The installed engine expects a wrapping bin symlink so `sudo theamify` works.
55
- fs.mkdirSync(path.dirname(USER_BIN_LINK), { recursive: true });
56
- fs.rmSync(USER_BIN_LINK, { force: true });
57
- fs.symlinkSync(path.join(USER_DIR, BIN_NAME), USER_BIN_LINK);
58
-
59
- // Ensure a PATH marker so the command is reachable from a fresh shell.
60
- await ensureOnPath(path.dirname(USER_BIN_LINK));
93
+ removeShadowBin();
61
94
  return USER_DIR;
62
95
  }
63
96
 
64
- /** Add a PATH export marker to ~/.bashrc and ~/.zshrc (idempotent). */
65
- export function ensureOnPath(binDir) {
66
- const marker = '# theamify CLI PATH';
67
- const rcs = ['.bashrc', '.zshrc'].map((f) => path.join(os.homedir(), f));
68
- for (const rc of rcs) {
69
- try {
70
- const content = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
71
- if (content.includes(marker)) continue;
72
- fs.appendFileSync(rc, `\n${marker}\nexport PATH="${binDir}:$PATH"\n`);
73
- } catch { /* skip */ }
97
+ /**
98
+ * Repair a broken install: re-provision the engine/runtime from the bundled
99
+ * package (fixes a missing/corrupt engine or runtime tree) and remove any stale
100
+ * shadow symlink. Downloads are preserved unless the whole runtime is missing.
101
+ * @returns {Promise<string>} path to the repaired engine
102
+ */
103
+ export async function repairRuntime() {
104
+ const found = findInstalledRuntime();
105
+ if (found) {
106
+ syncEngineTo(found.dir);
107
+ removeShadowBin();
108
+ return path.join(found.dir, BIN_NAME);
74
109
  }
110
+ const dir = await installUserRuntime();
111
+ removeShadowBin();
112
+ return path.join(dir, BIN_NAME);
75
113
  }
76
114
 
77
115
  /**
@@ -82,8 +120,13 @@ export function ensureOnPath(binDir) {
82
120
  */
83
121
  export async function resolveEngine() {
84
122
  const found = findInstalledRuntime();
85
- if (found) return path.join(found.dir, BIN_NAME);
123
+ if (found) {
124
+ syncEngineTo(found.dir);
125
+ removeShadowBin();
126
+ return path.join(found.dir, BIN_NAME);
127
+ }
86
128
  const dir = await installUserRuntime();
129
+ removeShadowBin();
87
130
  return path.join(dir, BIN_NAME);
88
131
  }
89
132
 
@@ -97,4 +140,4 @@ export async function runEngine(args = []) {
97
140
  const { execa } = await import('execa');
98
141
  const engine = await resolveEngine();
99
142
  return execa('bash', [engine, ...args], { stdio: 'inherit', reject: false });
100
- }
143
+ }
@@ -0,0 +1,94 @@
1
+ import { execa } from 'execa';
2
+ import pc from 'picocolors';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const pkg = require('../../package.json');
7
+
8
+ export const NPM_NAME = pkg.name;
9
+ export const BIN_NAME = 'theamify';
10
+
11
+ /** Simple numeric semver compare: returns <0, 0, >0 when a is older/equal/newer. */
12
+ export function compareVersions(a, b) {
13
+ const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0);
14
+ const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0);
15
+ for (let i = 0; i < 3; i++) {
16
+ const d = (pa[i] || 0) - (pb[i] || 0);
17
+ if (d !== 0) return d < 0 ? -1 : 1;
18
+ }
19
+ return 0;
20
+ }
21
+
22
+ /** Query npm for the latest published version of this package. */
23
+ export async function getLatestVersion() {
24
+ try {
25
+ const { stdout } = await execa('npm', ['view', NPM_NAME, 'version'], { reject: false });
26
+ const v = (stdout || '').trim();
27
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Check whether the running local version is behind the latest published one.
35
+ * @returns {Promise<{outdated:boolean, latest:string|null, current:string}>}
36
+ */
37
+ export async function checkForUpdate() {
38
+ const latest = await getLatestVersion();
39
+ if (!latest) return { outdated: false, latest: null, current: pkg.version };
40
+ return { outdated: compareVersions(latest, pkg.version) > 0, latest, current: pkg.version };
41
+ }
42
+
43
+ /**
44
+ * If a newer version is available, offer to self-update via `npm install -g`.
45
+ * @returns {Promise<boolean>} true when an update was performed
46
+ */
47
+ export async function promptSelfUpdate() {
48
+ const { outdated, latest, current } = await checkForUpdate();
49
+ if (!outdated || !latest) return false;
50
+
51
+ const p = await import('@clack/prompts');
52
+ const want = await p.confirm({
53
+ message: `A new version (${pc.cyan('v' + latest)}) is available — you have ${pc.dim('v' + current)}. Update now?`,
54
+ initialValue: true,
55
+ });
56
+ if (p.isCancel(want) || !want) {
57
+ p.log.message(pc.dim(`Keeping v${current} — update later with: npm install -g ${NPM_NAME}@latest`));
58
+ return false;
59
+ }
60
+
61
+ // Release the terminal so npm's output is visible & interruptible.
62
+ console.log();
63
+ const res = await execa('npm', ['install', '-g', `${NPM_NAME}@latest`], { stdio: 'inherit', reject: false });
64
+ if (res.exitCode !== 0) {
65
+ p.log.warn('Update failed. You can retry with: npm install -g ' + NPM_NAME + '@latest');
66
+ return false;
67
+ }
68
+ p.log.success(`Updated to v${latest}. Restart ${BIN_NAME} to use the new version.`);
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Fully remove the npm package so the `theamify` command disappears from PATH.
74
+ * @returns {Promise<boolean>} true when uninstalled
75
+ */
76
+ export async function selfUninstall() {
77
+ const p = await import('@clack/prompts');
78
+ const want = await p.confirm({
79
+ message: `Also remove the ${NPM_NAME} npm package, so the ${pc.cyan(BIN_NAME)} command is gone from your system?`,
80
+ initialValue: true,
81
+ });
82
+ if (p.isCancel(want) || !want) {
83
+ p.log.message(pc.dim(`Keeping the npm package — the ${BIN_NAME} command will remain.`));
84
+ return false;
85
+ }
86
+ console.log();
87
+ const res = await execa('npm', ['uninstall', '-g', NPM_NAME], { stdio: 'inherit', reject: false });
88
+ if (res.exitCode === 0) {
89
+ p.log.success(`${NPM_NAME} removed. The ${BIN_NAME} command is no longer available.`);
90
+ return true;
91
+ }
92
+ p.log.warn(`Could not uninstall ${NPM_NAME} automatically. Run: npm uninstall -g ${NPM_NAME}`);
93
+ return false;
94
+ }
@@ -91,17 +91,57 @@ async function ensureCached(engine, name) {
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * Download every registry theme into the local cache during setup. Runs the
96
+ * engine's `get --all`, which skips anything already cached and never prompts
97
+ * per-theme. Returns the number cached afterwards.
98
+ */
99
+ async function ensureAllThemes(engine, { prompt = true } = {}) {
100
+ if (prompt) {
101
+ const want = await p.confirm({
102
+ message: 'Download all themes now (so every theme has an instant preview)?',
103
+ initialValue: true,
104
+ });
105
+ if (p.isCancel(want) || !want) {
106
+ p.log.message(pc.dim('Skipped — you can download individual themes from the menu.'));
107
+ return;
108
+ }
109
+ }
110
+
111
+ const s = p.spinner();
112
+ s.start('Downloading all themes…');
113
+ try {
114
+ await execa('bash', [engine, 'get', '--all'], { stdio: 'inherit', reject: true });
115
+ s.stop(pc.green('All themes downloaded.'));
116
+ } catch (e) {
117
+ s.stop(pc.red(`Theme download incomplete: ${e.message}`));
118
+ }
119
+ }
120
+
94
121
  /**
95
122
  * Interactive GRUB-theme browser wizard (mirrors GitSwitch's look & feel).
96
123
  * Stays in a menu loop: pick a theme → preview / download / apply / open →
97
124
  * back to the theme list — it NEVER hard-exits until you choose Quit.
98
125
  */
99
126
  export async function runThemeBrowser() {
127
+ // Step 0: self-check — offer an update if a newer version is published, and
128
+ // repair a broken/unprovisioned runtime before anything else.
129
+ const { checkForUpdate, promptSelfUpdate } = await import('../lib/self.js');
130
+ const { repairRuntime } = await import('../core/engine.js');
131
+ const { outdated } = await checkForUpdate();
132
+ if (outdated) await promptSelfUpdate();
133
+ await repairRuntime();
134
+
100
135
  // Step 1: ensure companion tools (chafa, grub-customizer) — detect, install
101
136
  // or update, then continue BEFORE showing the theme picker.
102
137
  await ensureManagedTools();
103
138
 
104
139
  const engine = await resolveEngine();
140
+
141
+ // Step 2: install-flow — make all themes downloadable/previewable up front so
142
+ // the user never has to fetch one just to see it.
143
+ await ensureAllThemes(engine, { prompt: true });
144
+
105
145
  let quit = false;
106
146
 
107
147
  while (!quit) {
@@ -140,7 +180,6 @@ export async function runThemeBrowser() {
140
180
  const action = await p.select({
141
181
  message: `What do you want to do with ${pc.cyan(picked.name)}?`,
142
182
  options: [
143
- { value: 'preview', label: 'Preview terminal thumbnail', hint: cached ? 'renders below' : 'downloads first' },
144
183
  { value: 'get', label: 'Download / update it', hint: 'git clone into local cache' },
145
184
  { value: 'use', label: 'Apply to GRUB', hint: 'needs sudo; rebuilds GRUB' },
146
185
  { value: 'open', label: 'Open source page in browser' },
@@ -152,11 +191,7 @@ export async function runThemeBrowser() {
152
191
  if (p.isCancel(action) || action === 'quit') { quit = true; break; }
153
192
  if (action === 'menu') { backToMenu = true; break; }
154
193
 
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') {
194
+ if (action === 'get') {
160
195
  await ensureCached(engine, picked.name);
161
196
  } else if (action === 'use') {
162
197
  await runUse(engine, picked.name);
package/vendor/theamify CHANGED
@@ -10,7 +10,7 @@ set -euo pipefail
10
10
  # -----------------------------------------------------------------------------
11
11
  # VERSION & TOOL NAME
12
12
  # -----------------------------------------------------------------------------
13
- readonly VERSION="1.0.0"
13
+ readonly VERSION="2.2.0"
14
14
  readonly TOOL="theamify"
15
15
 
16
16
  # -----------------------------------------------------------------------------