theamify-cli 1.0.1
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/LICENSE +21 -0
- package/README.md +286 -0
- package/bin/theamify.js +2 -0
- package/package.json +52 -0
- package/src/cli.js +109 -0
- package/src/commands/manage.js +122 -0
- package/src/core/engine.js +100 -0
- package/src/lib/chafa.js +143 -0
- package/src/lib/conf.js +55 -0
- package/src/lib/tools.js +76 -0
- package/src/wizard/browse.js +165 -0
- package/vendor/config/themes.conf +36 -0
- package/vendor/lib/colors.sh +151 -0
- package/vendor/lib/grub.sh +179 -0
- package/vendor/lib/themes.sh +283 -0
- package/vendor/lib/utils.sh +135 -0
- package/vendor/theamify +565 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Absolute path to the bundled bash engine + app tree shipped inside the npm
|
|
8
|
+
* package. The whole engine (script + lib/ + config/) lives under vendor/.
|
|
9
|
+
*/
|
|
10
|
+
export const VENDOR_DIR = fileURLToPath(new URL('../../vendor', import.meta.url));
|
|
11
|
+
export const ENGINE_SCRIPT = path.join(VENDOR_DIR, 'theamify');
|
|
12
|
+
|
|
13
|
+
/** Install/run-time locations. */
|
|
14
|
+
export const SYSTEM_DIR = '/usr/local/share/theamify';
|
|
15
|
+
export const USER_DIR = path.join(os.homedir(), '.local', 'share', 'theamify');
|
|
16
|
+
export const BIN_NAME = 'theamify';
|
|
17
|
+
export const USER_BIN_LINK = path.join(os.homedir(), '.local', 'bin', BIN_NAME);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Locate an installed theamify runtime.
|
|
21
|
+
* @returns {{dir: string}|null}
|
|
22
|
+
*/
|
|
23
|
+
export function findInstalledRuntime() {
|
|
24
|
+
for (const dir of [SYSTEM_DIR, USER_DIR]) {
|
|
25
|
+
if (fs.existsSync(path.join(dir, BIN_NAME))) return { dir };
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Provision a user-owned install of the engine under ~/.local/share/theamify so
|
|
32
|
+
* it is writable without root (downloads write into themes/ and .repo_cache/).
|
|
33
|
+
* Preserves an existing config/themes.conf (user registry edits). Returns the
|
|
34
|
+
* runtime dir.
|
|
35
|
+
*/
|
|
36
|
+
export async function installUserRuntime() {
|
|
37
|
+
const { execaSync } = await import('execa');
|
|
38
|
+
fs.mkdirSync(path.join(USER_DIR, 'lib'), { recursive: true });
|
|
39
|
+
fs.mkdirSync(path.join(USER_DIR, 'config'), { recursive: true });
|
|
40
|
+
fs.mkdirSync(path.join(USER_DIR, 'themes'), { recursive: true });
|
|
41
|
+
fs.mkdirSync(path.join(USER_DIR, '.repo_cache'), { recursive: true });
|
|
42
|
+
|
|
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
|
+
}
|
|
48
|
+
const confSrc = path.join(VENDOR_DIR, 'config', 'themes.conf');
|
|
49
|
+
const confDst = path.join(USER_DIR, 'config', 'themes.conf');
|
|
50
|
+
if (!fs.existsSync(confDst)) {
|
|
51
|
+
fs.copyFileSync(confSrc, confDst);
|
|
52
|
+
}
|
|
53
|
+
|
|
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));
|
|
61
|
+
return USER_DIR;
|
|
62
|
+
}
|
|
63
|
+
|
|
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
|
+
/**
|
|
78
|
+
* Resolve the path to the engine to run for a subcommand.
|
|
79
|
+
* Prefers an installed runtime; otherwise provisions the bundled one in the
|
|
80
|
+
* user share dir (so downloads are writable) and returns that.
|
|
81
|
+
* @returns {string} absolute path to the theamify engine
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveEngine() {
|
|
84
|
+
const found = findInstalledRuntime();
|
|
85
|
+
if (found) return path.join(found.dir, BIN_NAME);
|
|
86
|
+
const dir = await installUserRuntime();
|
|
87
|
+
return path.join(dir, BIN_NAME);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run the engine (installed or bundled) with inherited stdio so the rich TUI
|
|
92
|
+
* and all subcommands behave exactly like the native tool.
|
|
93
|
+
* @param {string[]} args
|
|
94
|
+
* @returns {Promise<{exitCode: number|null}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function runEngine(args = []) {
|
|
97
|
+
const { execa } = await import('execa');
|
|
98
|
+
const engine = await resolveEngine();
|
|
99
|
+
return execa('bash', [engine, ...args], { stdio: 'inherit', reject: false });
|
|
100
|
+
}
|
package/src/lib/chafa.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
}
|
package/src/lib/conf.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { USER_DIR } from '../core/engine.js';
|
|
5
|
+
|
|
6
|
+
export const VENDOR_CONF = fileURLToPath(new URL('../../vendor/config/themes.conf', import.meta.url));
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse the `themes.conf` registry (NAME|URL|SUBDIR|DESC|SOURCE|TAGS).
|
|
10
|
+
* Skips blank lines and `#` comments.
|
|
11
|
+
* @param {string} [file] path to themes.conf (defaults to vendored copy)
|
|
12
|
+
* @returns {Array<{name:string,url:string,sub:string,desc:string,source:string,tags:string[]}>}
|
|
13
|
+
*/
|
|
14
|
+
export function parseThemes(file = VENDOR_CONF) {
|
|
15
|
+
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const raw of content.split('\n')) {
|
|
18
|
+
const line = raw.trim();
|
|
19
|
+
if (!line || line.startsWith('#')) continue;
|
|
20
|
+
const parts = line.split('|');
|
|
21
|
+
const [name, url, sub, desc, source, tags] = parts;
|
|
22
|
+
if (!name || !url) continue;
|
|
23
|
+
out.push({
|
|
24
|
+
name: name.trim(),
|
|
25
|
+
url: url.trim(),
|
|
26
|
+
sub: (sub || '.').trim(),
|
|
27
|
+
desc: (desc || '').trim(),
|
|
28
|
+
source: (source || '').trim(),
|
|
29
|
+
tags: (tags || '').split(',').map((t) => t.trim()).filter(Boolean),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find a theme by name (case-insensitive).
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @param {string} [file]
|
|
39
|
+
* @returns {object|null}
|
|
40
|
+
*/
|
|
41
|
+
export function findTheme(name, file = VENDOR_CONF) {
|
|
42
|
+
if (!name) return null;
|
|
43
|
+
return parseThemes(file).find((t) => t.name.toLowerCase() === name.toLowerCase()) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the registry file actually in use by the installed runtime when one
|
|
48
|
+
* exists (so user's `theamify add`/`del` edits are honored), else the vendored
|
|
49
|
+
* default shipped in this package.
|
|
50
|
+
* @returns {string} path to themes.conf (runtime or vendored)
|
|
51
|
+
*/
|
|
52
|
+
export function resolveConfPath() {
|
|
53
|
+
const runtimeConf = path.join(USER_DIR, 'config', 'themes.conf');
|
|
54
|
+
return fs.existsSync(runtimeConf) ? runtimeConf : VENDOR_CONF;
|
|
55
|
+
}
|
package/src/lib/tools.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execa, execaSync } from 'execa';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* theamify companion tools β installed / updated by the wizard, but NEVER
|
|
7
|
+
* removed on uninstall (they're useful to the user on their own).
|
|
8
|
+
*
|
|
9
|
+
* Registry:
|
|
10
|
+
* - chafa terminal image renderer β inline theme thumbnails
|
|
11
|
+
* - grub-customizer GUI to tweak the GRUB menu & themes
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'chafa',
|
|
17
|
+
bin: 'chafa',
|
|
18
|
+
purpose: 'terminal thumbnail previews',
|
|
19
|
+
install: { 'apt-get': 'chafa', 'dnf': 'chafa', 'yum': 'chafa', 'pacman': 'chafa', 'zypper': 'chafa', 'apk': 'chafa' },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'grub-customizer',
|
|
23
|
+
bin: 'grub-customizer',
|
|
24
|
+
purpose: 'GUI for tweaking the GRUB menu & themes',
|
|
25
|
+
install: { 'apt-get': 'grub-customizer', 'dnf': 'grub-customizer', 'yum': 'grub-customizer', 'pacman': 'grub-customizer', 'zypper': 'grub-customizer', 'apk': null },
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** @param {{bin:string}} tool */
|
|
30
|
+
export async function hasTool(tool) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await execa('bash', ['-c', `command -v ${tool.bin}`], { reject: false });
|
|
33
|
+
return Boolean(res.stdout.trim());
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Detect which package manager is available. @returns {string|null} */
|
|
40
|
+
export function detectPackageManager() {
|
|
41
|
+
const order = ['apt-get', 'dnf', 'yum', 'pacman', 'zypper', 'apk'];
|
|
42
|
+
const pm = order.find((name) => {
|
|
43
|
+
try {
|
|
44
|
+
return Boolean(execaSync('bash', ['-c', `command -v ${name}`], { reject: false }).stdout.trim());
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return pm || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a privileged package-manager command, releasing the terminal first so the
|
|
54
|
+
* sudo password prompt is visible and Ctrl+C actually works. Mirrors the
|
|
55
|
+
* interactive-sudo pattern used by gitswitch and warp-wizard.
|
|
56
|
+
* @param {string[]} cmd sudo + args
|
|
57
|
+
*/
|
|
58
|
+
async function runPrivileged(cmd) {
|
|
59
|
+
const res = await execa('sudo', cmd, { stdio: 'inherit', reject: false });
|
|
60
|
+
if (res.exitCode !== 0) throw new Error(`Command failed: sudo ${cmd.join(' ')} (exit ${res.exitCode})`);
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @returns {string|null} package-manager-specific package name for a tool */
|
|
65
|
+
function packageNameFor(tool, pm) {
|
|
66
|
+
return tool.install ? tool.install[pm] : (tool.name || null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @returns {boolean} whether the tool binary is on PATH (sync) */
|
|
70
|
+
function hasToolSync(tool) {
|
|
71
|
+
try {
|
|
72
|
+
return Boolean(execaSync('bash', ['-c', `command -v ${tool.bin}`], { reject: false }).stdout.trim());
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseThemes, resolveConfPath } from '../lib/conf.js';
|
|
6
|
+
import { ensureChafa } from '../lib/chafa.js';
|
|
7
|
+
import { resolveEngine, USER_DIR } from '../core/engine.js';
|
|
8
|
+
|
|
9
|
+
const USER_THEMES = path.join(USER_DIR, 'themes');
|
|
10
|
+
const GRUB_CFG = '/etc/default/grub';
|
|
11
|
+
|
|
12
|
+
/** Extract the currently-active theme folder name from /etc/default/grub. */
|
|
13
|
+
function activeTheme() {
|
|
14
|
+
try {
|
|
15
|
+
const cfg = fs.readFileSync(GRUB_CFG, 'utf8');
|
|
16
|
+
const m = cfg.match(/^GRUB_THEME="?([^"\n]+)"?/m);
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
return (path.basename(path.dirname(m[1])) || m[1]).trim() || null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function themeStatus(name) {
|
|
25
|
+
const active = activeTheme();
|
|
26
|
+
let status = 'remote';
|
|
27
|
+
if (fs.existsSync(path.join(USER_THEMES, name))) status = 'cached';
|
|
28
|
+
if (name === active) status = 'active';
|
|
29
|
+
return status;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const statusColor = (status) => ({
|
|
33
|
+
active: pc.green,
|
|
34
|
+
cached: pc.cyan,
|
|
35
|
+
remote: pc.dim,
|
|
36
|
+
}[status] || pc.dim);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render a terminal "thumbnail" preview of a cached theme via chafa (or any
|
|
40
|
+
* terminal image renderer the user has). Silently nops when unsatisfiable.
|
|
41
|
+
* @returns {Promise<boolean>} true if a preview was rendered
|
|
42
|
+
*/
|
|
43
|
+
export async function showThemePreview({ name, width = 40, height = 12 }) {
|
|
44
|
+
const dir = path.join(USER_THEMES, name);
|
|
45
|
+
if (!fs.existsSync(dir)) return false;
|
|
46
|
+
|
|
47
|
+
const image = fs.readdirSync(dir, { recursive: true })
|
|
48
|
+
.map((f) => path.join(dir, f))
|
|
49
|
+
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f) && !f.includes('/.git/'))
|
|
50
|
+
.sort()[0];
|
|
51
|
+
if (!image) return false;
|
|
52
|
+
|
|
53
|
+
const { execa } = await import('execa');
|
|
54
|
+
for (const tool of ['chafa', 'timg', 'viu', 'jp2a']) {
|
|
55
|
+
const args = tool === 'chafa'
|
|
56
|
+
? ['--size', `${width}x${height}`, image]
|
|
57
|
+
: [image];
|
|
58
|
+
try {
|
|
59
|
+
const { stdout } = await execa(tool, args, { reject: false });
|
|
60
|
+
if (stdout && stdout.trim()) {
|
|
61
|
+
p.log.message(`\n${stdout}\n`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
} catch { /* try next */ }
|
|
65
|
+
}
|
|
66
|
+
p.log.message(pc.dim('Install `chafa` (: sudo apt install chafa) for terminal theme previews.'));
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
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 });
|
|
97
|
+
}
|
|
98
|
+
|
|
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); }
|
|
110
|
+
|
|
111
|
+
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}`));
|
|
125
|
+
}
|
|
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
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Apply a theme to GRUB, re-invoking with sudo, spinner released for the prompt. */
|
|
134
|
+
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
|
+
}
|
|
148
|
+
|
|
149
|
+
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; }
|
|
151
|
+
|
|
152
|
+
const { execa } = await import('execa');
|
|
153
|
+
if (process.getuid() !== 0) {
|
|
154
|
+
// Release spinner, then prompt sudo on a clean terminal so Ctrl+C works.
|
|
155
|
+
console.log();
|
|
156
|
+
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.');
|
|
160
|
+
}
|
|
161
|
+
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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# +==============================================================================+
|
|
2
|
+
# | theamify Theme Registry |
|
|
3
|
+
# | Format : NAME|GITHUB_URL|SUBDIR|DESCRIPTION|SOURCE_URL|TAGS |
|
|
4
|
+
# | SUBDIR : "." = repo root | "path/to/dir" = subdir within repo |
|
|
5
|
+
# | "generate:<args>" = repo has no static theme folder, build it |
|
|
6
|
+
# | via its own generate.sh first (see CONTRIBUTING.md) |
|
|
7
|
+
# | Add : theamify add <url> |
|
|
8
|
+
# | Remove : theamify del <name> |
|
|
9
|
+
# +==============================================================================+
|
|
10
|
+
|
|
11
|
+
# -- HenriqueLopes42 Cyberpunk Series ------------------------------------------
|
|
12
|
+
CyberEXS|https://github.com/HenriqueLopes42/themeGrub.CyberEXS|.|Dark cyberpunk hooded-figure neon GRUB theme|https://www.gnome-look.org/p/1968990|cyberpunk,dark,neon,hooded,scifi
|
|
13
|
+
CyberSynchro|https://github.com/HenriqueLopes42/ThemeGrub.CyberSynchro|.|Teal & neon cyberpunk matrix GRUB theme|https://www.gnome-look.org/p/1972621|cyberpunk,teal,neon,matrix,dark
|
|
14
|
+
|
|
15
|
+
# -- Space & Sci-Fi -------------------------------------------------------------
|
|
16
|
+
Space-Isolation|https://github.com/callmenoodles/space-isolation|.|Alien: Isolation-inspired dark space GRUB theme|https://www.gnome-look.org/p/2296342|space,alien,scifi,dark,atmospheric,horror
|
|
17
|
+
|
|
18
|
+
# -- Anime / Kawaii ------------------------------------------------------------
|
|
19
|
+
# NOTE: upstream repo is "KawaiiGRUB" (double i) - a single-i URL 404s and
|
|
20
|
+
# makes git fall back to an interactive credential prompt over HTTPS, which
|
|
21
|
+
# hangs a non-interactive `get --all` run. Fixed here; see CONTRIBUTING.md.
|
|
22
|
+
Kawaii-GRUB|https://github.com/Gabbar-v7/KawaiiGRUB|.|Kawaii anime vibrant bold GRUB theme|https://www.gnome-look.org/p/2218890|anime,kawaii,cute,colorful,bold,vibrant
|
|
23
|
+
Kayoko-Onikata|https://github.com/MTFTau-5/Kayoko-Onikata-GRUB|.|Cat cafe anime cozy pastel GRUB theme|https://www.gnome-look.org/p/2350900|anime,cat,cute,cozy,pastel,kawaii
|
|
24
|
+
|
|
25
|
+
# -- Abstract / Tech -----------------------------------------------------------
|
|
26
|
+
# NOTE: yeyushengfan258's Matrix/Particle repos use the same "no static
|
|
27
|
+
# theme folder, build via generate.sh" pattern that the old Elegant entries
|
|
28
|
+
# used (and originally failed at). Verified working with the args below -
|
|
29
|
+
# generate.sh accepts -t [window|sidebar] and -s [1080p|2k|4k]; change either
|
|
30
|
+
# to taste. See CONTRIBUTING.md for how `generate:<args>` is driven.
|
|
31
|
+
Matrices|https://github.com/yeyushengfan258/Matrix-grub-theme|generate:-t window -s 1080p|Abstract matrix blue tech orb GRUB theme|https://www.gnome-look.org/p/2271298|matrix,abstract,blue,tech,modern,3d
|
|
32
|
+
Particle|https://github.com/yeyushengfan258/Particle-grub-theme|generate:-t window -s 1080p|Particle effect dynamic blue GRUB theme|https://www.gnome-look.org/p/2269763|particle,abstract,blue,dynamic,modern
|
|
33
|
+
|
|
34
|
+
# -- Chill / Minimal -----------------------------------------------------------
|
|
35
|
+
Zzz-GRUB|https://github.com/jsthope/Zzz-GRUB-Theme|.|Cute sleeping cat chill minimal GRUB theme|https://www.gnome-look.org/p/2354136|cat,chill,minimal,cute,anime,night,cozy
|
|
36
|
+
|