theamify-cli 2.0.0 → 2.1.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.1.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
@@ -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.
@@ -78,6 +78,20 @@ const main = defineCommand({
78
78
  case 'wizard':
79
79
  case 'browse':
80
80
  return runThemeBrowser();
81
+ case 'install':
82
+ // Guided setup: companion tools → download all themes.
83
+ return runThemeBrowser();
84
+ case 'help':
85
+ case '-h':
86
+ case '--help':
87
+ printUsage();
88
+ return;
89
+ case 'version':
90
+ case '-v':
91
+ case '-V':
92
+ case '--version':
93
+ console.log(`theamify v${pkg.version}`);
94
+ return;
81
95
  case 'status':
82
96
  return runStatus();
83
97
  case 'doctor':
@@ -6,7 +6,7 @@ import { execa, execaSync } from 'execa';
6
6
  import {
7
7
  findInstalledRuntime,
8
8
  USER_DIR,
9
- USER_BIN_LINK,
9
+ removeShadowBin,
10
10
  resolveEngine,
11
11
  } from '../core/engine.js';
12
12
  import { parseThemes, resolveConfPath } from '../lib/conf.js';
@@ -80,7 +80,35 @@ export async function runStatus() {
80
80
  if (res.exitCode !== 0) process.exit(res.exitCode);
81
81
  }
82
82
 
83
- /** `theamify uninstall` — interactive, every destructive step confirmed. */
83
+ /**
84
+ * Remove GRUB_THEME from /etc/default/grub and rebuild the boot menu so the
85
+ * system returns to the default theme. Requires sudo when not root.
86
+ * @returns {Promise<boolean>} true when GRUB was reset
87
+ */
88
+ async function resetGrubTheme() {
89
+ const script = `
90
+ GRUB=/etc/default/grub
91
+ [ -f "$GRUB" ] || exit 0
92
+ sed -i '/^GRUB_THEME=/d' "$GRUB"
93
+ if command -v update-grub >/dev/null 2>&1; then
94
+ update-grub
95
+ elif command -v grub-mkconfig >/dev/null 2>&1; then
96
+ grub-mkconfig -o /boot/grub/grub.cfg
97
+ elif command -v grub2-mkconfig >/dev/null 2>&1; then
98
+ grub2-mkconfig -o /boot/grub2/grub.cfg
99
+ fi
100
+ `.trim();
101
+ if (process.getuid() === 0) {
102
+ const res = await execa('bash', ['-c', script], { stdio: 'inherit', reject: false });
103
+ return res.exitCode === 0;
104
+ }
105
+ // Release the terminal so the sudo password prompt is visible & interruptible.
106
+ console.log();
107
+ const res = await execa('sudo', ['bash', '-c', script], { stdio: 'inherit', reject: false });
108
+ return res.exitCode === 0;
109
+ }
110
+
111
+ /** `theamify uninstall` — removes theamify, ALL downloaded themes, and resets GRUB to default. */
84
112
  export async function runUninstallWizard() {
85
113
  p.intro(pc.bgRed(pc.black(' theamify Uninstaller ')));
86
114
  const found = findInstalledRuntime();
@@ -89,7 +117,7 @@ export async function runUninstallWizard() {
89
117
  let removed = false;
90
118
  if (found) {
91
119
  const confirm = await p.confirm({
92
- message: `Remove theamify files at ${found.dir}/?`,
120
+ message: `Remove theamify files at ${found.dir}/? (this deletes ALL downloaded themes)`,
93
121
  initialValue: true,
94
122
  });
95
123
  if (!p.isCancel(confirm) && confirm) {
@@ -102,26 +130,28 @@ export async function runUninstallWizard() {
102
130
  fs.rmSync(dir, { recursive: true, force: true });
103
131
  }
104
132
  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
- }
133
+ removeShadowBin();
109
134
  } catch {
110
135
  p.log.warn(`Could not remove ${found.dir}.`);
111
136
  }
112
137
  }
113
138
  }
114
139
 
115
- const keepGrub = await p.confirm({
116
- message: 'Keep your currently-applied GRUB theme? (recommended)',
140
+ // Reset the boot menu back to the default (remove the applied GRUB theme).
141
+ const resetGrub = await p.confirm({
142
+ message: 'Remove your applied GRUB theme and reset to the default boot menu?',
117
143
  initialValue: true,
118
144
  });
119
- if (p.isCancel(keepGrub)) { p.cancel('Aborted.'); process.exit(0); }
145
+ if (p.isCancel(resetGrub)) { p.cancel('Aborted.'); process.exit(0); }
146
+ const grubReset = resetGrub ? await resetGrubTheme() : false;
120
147
 
121
148
  // Companion tools (chafa, grub-customizer) are intentionally LEFT in place —
122
149
  // the user may want them for later; uninstall only removes theamify itself.
150
+ const themeNote = grubReset
151
+ ? 'Boot menu reset to the default theme.'
152
+ : (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
153
 
124
154
  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.`,
155
+ `Uninstalled.${removed ? ' Runtime + all downloaded themes removed.' : ''} ${themeNote} Companion tools (chafa, grub-customizer) were kept for your use.`,
126
156
  ));
127
157
  }
@@ -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,53 +28,72 @@ 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 */ }
74
- }
75
- }
76
-
77
97
  /**
78
98
  * Resolve the path to the engine to run for a subcommand.
79
99
  * Prefers an installed runtime; otherwise provisions the bundled one in the
@@ -82,8 +102,13 @@ export function ensureOnPath(binDir) {
82
102
  */
83
103
  export async function resolveEngine() {
84
104
  const found = findInstalledRuntime();
85
- if (found) return path.join(found.dir, BIN_NAME);
105
+ if (found) {
106
+ syncEngineTo(found.dir);
107
+ removeShadowBin();
108
+ return path.join(found.dir, BIN_NAME);
109
+ }
86
110
  const dir = await installUserRuntime();
111
+ removeShadowBin();
87
112
  return path.join(dir, BIN_NAME);
88
113
  }
89
114
 
@@ -97,4 +122,4 @@ export async function runEngine(args = []) {
97
122
  const { execa } = await import('execa');
98
123
  const engine = await resolveEngine();
99
124
  return execa('bash', [engine, ...args], { stdio: 'inherit', reject: false });
100
- }
125
+ }
@@ -91,6 +91,33 @@ 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 →
@@ -102,6 +129,11 @@ export async function runThemeBrowser() {
102
129
  await ensureManagedTools();
103
130
 
104
131
  const engine = await resolveEngine();
132
+
133
+ // Step 2: install-flow — make all themes downloadable/previewable up front so
134
+ // the user never has to fetch one just to see it.
135
+ await ensureAllThemes(engine, { prompt: true });
136
+
105
137
  let quit = false;
106
138
 
107
139
  while (!quit) {
@@ -140,7 +172,6 @@ export async function runThemeBrowser() {
140
172
  const action = await p.select({
141
173
  message: `What do you want to do with ${pc.cyan(picked.name)}?`,
142
174
  options: [
143
- { value: 'preview', label: 'Preview terminal thumbnail', hint: cached ? 'renders below' : 'downloads first' },
144
175
  { value: 'get', label: 'Download / update it', hint: 'git clone into local cache' },
145
176
  { value: 'use', label: 'Apply to GRUB', hint: 'needs sudo; rebuilds GRUB' },
146
177
  { value: 'open', label: 'Open source page in browser' },
@@ -152,11 +183,7 @@ export async function runThemeBrowser() {
152
183
  if (p.isCancel(action) || action === 'quit') { quit = true; break; }
153
184
  if (action === 'menu') { backToMenu = true; break; }
154
185
 
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') {
186
+ if (action === 'get') {
160
187
  await ensureCached(engine, picked.name);
161
188
  } else if (action === 'use') {
162
189
  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.1.0"
14
14
  readonly TOOL="theamify"
15
15
 
16
16
  # -----------------------------------------------------------------------------