Skip to content

Commit cd60547

Browse files
committed
feat(cli): improve status, theme, and docs terminal UX
1 parent 6422576 commit cd60547

15 files changed

Lines changed: 1313 additions & 108 deletions

File tree

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"clean": "rm -rf dist",
2323
"prebuild": "npm run clean",
2424
"prepublishOnly": "npm run build",
25-
"test": "echo \"Error: no test specified\" && exit 1"
25+
"test": "npm run build && bash scripts/test-cli.sh"
2626
},
2727
"keywords": [
2828
"cli",

scripts/test-cli.sh

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
help_output="$(node dist/index.js --help)"
5+
if [[ "$help_output" != *"Usage:"* ]]; then
6+
echo "help output missing Usage section" >&2
7+
exit 1
8+
fi
9+
10+
roadmap_output="$(node dist/index.js roadmap)"
11+
if [[ "$roadmap_output" != "https://github.com/orgs/nbtca/projects/5" ]]; then
12+
echo "roadmap output mismatch: $roadmap_output" >&2
13+
exit 1
14+
fi
15+
16+
docs_output="$(node dist/index.js docs)"
17+
if [[ "$docs_output" != "https://docs.nbtca.space" ]]; then
18+
echo "docs output mismatch: $docs_output" >&2
19+
exit 1
20+
fi
21+
22+
tmp_home="$(mktemp -d)"
23+
HOME="$tmp_home" node dist/index.js theme icon ascii >/dev/null
24+
if ! grep -q '"iconMode": "ascii"' "$tmp_home/.nbtca/preferences.json"; then
25+
echo "theme preference was not persisted" >&2
26+
rm -rf "$tmp_home"
27+
exit 1
28+
fi
29+
rm -rf "$tmp_home"
30+
31+
unknown_flag_stderr="$(mktemp)"
32+
if node dist/index.js roadmap --oops >/dev/null 2>"$unknown_flag_stderr"; then
33+
echo "expected unknown flag to fail" >&2
34+
rm -f "$unknown_flag_stderr"
35+
exit 1
36+
fi
37+
if ! grep -q "Unknown flag: --oops" "$unknown_flag_stderr"; then
38+
echo "unknown flag error message mismatch" >&2
39+
rm -f "$unknown_flag_stderr"
40+
exit 1
41+
fi
42+
rm -f "$unknown_flag_stderr"
43+
44+
status_conflict_stderr="$(mktemp)"
45+
if node dist/index.js status --watch --json >/dev/null 2>"$status_conflict_stderr"; then
46+
echo "expected status --watch --json to fail" >&2
47+
rm -f "$status_conflict_stderr"
48+
exit 1
49+
fi
50+
if ! grep -q -- "--watch" "$status_conflict_stderr"; then
51+
echo "status watch/json conflict message mismatch" >&2
52+
rm -f "$status_conflict_stderr"
53+
exit 1
54+
fi
55+
rm -f "$status_conflict_stderr"
56+
57+
status_interval_stderr="$(mktemp)"
58+
if node dist/index.js status --interval=8 >/dev/null 2>"$status_interval_stderr"; then
59+
echo "expected status --interval without --watch to fail" >&2
60+
rm -f "$status_interval_stderr"
61+
exit 1
62+
fi
63+
if ! grep -q -- "--interval" "$status_interval_stderr"; then
64+
echo "status interval validation message mismatch" >&2
65+
rm -f "$status_interval_stderr"
66+
exit 1
67+
fi
68+
rm -f "$status_interval_stderr"
69+
70+
status_interval_bounds_stderr="$(mktemp)"
71+
if node dist/index.js status --watch --interval=1 >/dev/null 2>"$status_interval_bounds_stderr"; then
72+
echo "expected status --watch --interval=1 to fail" >&2
73+
rm -f "$status_interval_bounds_stderr"
74+
exit 1
75+
fi
76+
if ! grep -q -- "--interval=<" "$status_interval_bounds_stderr"; then
77+
echo "status interval bounds message mismatch" >&2
78+
rm -f "$status_interval_bounds_stderr"
79+
exit 1
80+
fi
81+
rm -f "$status_interval_bounds_stderr"
82+
83+
status_timeout_bounds_stderr="$(mktemp)"
84+
if node dist/index.js status --timeout=500 >/dev/null 2>"$status_timeout_bounds_stderr"; then
85+
echo "expected status --timeout=500 to fail" >&2
86+
rm -f "$status_timeout_bounds_stderr"
87+
exit 1
88+
fi
89+
if ! grep -q -- "--timeout=<" "$status_timeout_bounds_stderr"; then
90+
echo "status timeout bounds message mismatch" >&2
91+
rm -f "$status_timeout_bounds_stderr"
92+
exit 1
93+
fi
94+
rm -f "$status_timeout_bounds_stderr"
95+
96+
status_retries_bounds_stderr="$(mktemp)"
97+
if node dist/index.js status --retries=9 >/dev/null 2>"$status_retries_bounds_stderr"; then
98+
echo "expected status --retries=9 to fail" >&2
99+
rm -f "$status_retries_bounds_stderr"
100+
exit 1
101+
fi
102+
if ! grep -q -- "--retries=<" "$status_retries_bounds_stderr"; then
103+
echo "status retries bounds message mismatch" >&2
104+
rm -f "$status_retries_bounds_stderr"
105+
exit 1
106+
fi
107+
rm -f "$status_retries_bounds_stderr"
108+
109+
interactive_stderr="$(mktemp)"
110+
if node dist/index.js >/dev/null 2>"$interactive_stderr"; then
111+
echo "expected interactive mode to fail without TTY" >&2
112+
rm -f "$interactive_stderr"
113+
exit 1
114+
fi
115+
if ! grep -q "Interactive mode requires a TTY terminal" "$interactive_stderr"; then
116+
echo "non-TTY interactive error message mismatch" >&2
117+
rm -f "$interactive_stderr"
118+
exit 1
119+
fi
120+
rm -f "$interactive_stderr"
121+
122+
echo "CLI contract tests passed."

src/config/preferences.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
export type IconMode = 'auto' | 'ascii' | 'unicode';
5+
export type ColorMode = 'auto' | 'on' | 'off';
6+
7+
export interface Preferences {
8+
iconMode: IconMode;
9+
colorMode: ColorMode;
10+
}
11+
12+
const DEFAULT_PREFERENCES: Preferences = {
13+
iconMode: 'auto',
14+
colorMode: 'auto',
15+
};
16+
17+
function getConfigDir(): string {
18+
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
19+
return path.join(homeDir, '.nbtca');
20+
}
21+
22+
function getPreferencesPath(): string {
23+
return path.join(getConfigDir(), 'preferences.json');
24+
}
25+
26+
function ensureConfigDir(): void {
27+
const configDir = getConfigDir();
28+
if (!fs.existsSync(configDir)) {
29+
fs.mkdirSync(configDir, { recursive: true });
30+
}
31+
}
32+
33+
export function loadPreferences(): Preferences {
34+
try {
35+
const prefPath = getPreferencesPath();
36+
if (!fs.existsSync(prefPath)) {
37+
return { ...DEFAULT_PREFERENCES };
38+
}
39+
const raw = JSON.parse(fs.readFileSync(prefPath, 'utf-8')) as Partial<Preferences>;
40+
const iconMode: IconMode =
41+
raw.iconMode === 'ascii' || raw.iconMode === 'unicode' || raw.iconMode === 'auto'
42+
? raw.iconMode
43+
: DEFAULT_PREFERENCES.iconMode;
44+
const colorMode: ColorMode =
45+
raw.colorMode === 'on' || raw.colorMode === 'off' || raw.colorMode === 'auto'
46+
? raw.colorMode
47+
: DEFAULT_PREFERENCES.colorMode;
48+
return { iconMode, colorMode };
49+
} catch {
50+
return { ...DEFAULT_PREFERENCES };
51+
}
52+
}
53+
54+
function savePreferences(preferences: Preferences): boolean {
55+
try {
56+
ensureConfigDir();
57+
fs.writeFileSync(getPreferencesPath(), JSON.stringify(preferences, null, 2));
58+
return true;
59+
} catch {
60+
return false;
61+
}
62+
}
63+
64+
export function setIconMode(mode: IconMode): boolean {
65+
const prefs = loadPreferences();
66+
prefs.iconMode = mode;
67+
return savePreferences(prefs);
68+
}
69+
70+
export function setColorMode(mode: ColorMode): boolean {
71+
const prefs = loadPreferences();
72+
prefs.colorMode = mode;
73+
return savePreferences(prefs);
74+
}
75+
76+
export function resetPreferences(): boolean {
77+
return savePreferences({ ...DEFAULT_PREFERENCES });
78+
}
79+
80+
export function resolveIconMode(): IconMode {
81+
const env = (process.env['NBTCA_ICON_MODE'] || '').toLowerCase();
82+
if (env === 'ascii' || env === 'unicode' || env === 'auto') {
83+
return env;
84+
}
85+
return loadPreferences().iconMode;
86+
}
87+
88+
export function resolveColorMode(): ColorMode {
89+
const env = (process.env['NBTCA_COLOR_MODE'] || '').toLowerCase();
90+
if (env === 'on' || env === 'off' || env === 'auto') {
91+
return env;
92+
}
93+
return loadPreferences().colorMode;
94+
}
95+
96+
export function applyColorModePreference(forcePlain: boolean): void {
97+
if (forcePlain) {
98+
process.env['NO_COLOR'] = '1';
99+
return;
100+
}
101+
102+
const mode = resolveColorMode();
103+
if (mode === 'off') {
104+
process.env['NO_COLOR'] = '1';
105+
return;
106+
}
107+
if (mode === 'on') {
108+
delete process.env['NO_COLOR'];
109+
}
110+
}

src/core/icons.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { resolveIconMode } from '../config/preferences.js';
2+
3+
function localeSupportsUnicode(): boolean {
4+
const locale = `${process.env['LC_ALL'] || ''} ${process.env['LANG'] || ''}`.toLowerCase();
5+
return locale.includes('utf-8') || locale.includes('utf8');
6+
}
7+
8+
export function useUnicodeIcons(): boolean {
9+
const configured = resolveIconMode();
10+
if (configured === 'ascii') return false;
11+
if (configured === 'unicode') return true;
12+
13+
const term = (process.env['TERM'] || '').toLowerCase();
14+
if (!process.stdout.isTTY || term === 'dumb') return false;
15+
return localeSupportsUnicode();
16+
}
17+
18+
export function pickIcon(unicodeIcon: string, asciiIcon: string): string {
19+
return useUnicodeIcons() ? unicodeIcon : asciiIcon;
20+
}

src/core/menu.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import chalk from 'chalk';
88
import { showCalendar } from '../features/calendar.js';
99
import { openRepairService } from '../features/repair.js';
1010
import { showDocsMenu } from '../features/docs.js';
11+
import { showServiceStatus } from '../features/status.js';
12+
import { showThemeMenu } from '../features/theme.js';
1113
import { openHomepage, openGithub, openRoadmap } from '../features/website.js';
12-
import { printDivider, printNewLine, success } from './ui.js';
14+
import { printDivider, printNewLine, success, warning } from './ui.js';
15+
import { pickIcon } from './icons.js';
1316
import { padEndV } from './text.js';
1417
import { APP_INFO, URLS } from '../config/data.js';
1518
import { t, getCurrentLanguage, setLanguage, clearTranslationCache, type Language } from '../i18n/index.js';
1619

17-
export type MenuAction = 'events' | 'repair' | 'docs' | 'links' | 'website' | 'github' | 'roadmap' | 'about' | 'language';
20+
export type MenuAction = 'events' | 'repair' | 'docs' | 'status' | 'links' | 'website' | 'github' | 'roadmap' | 'about' | 'language' | 'theme';
1821

1922
/**
2023
* Get main menu options — 6 items
@@ -25,8 +28,10 @@ function getMainMenuOptions() {
2528
{ value: 'events', label: trans.menu.events, hint: trans.menu.eventsDesc },
2629
{ value: 'repair', label: trans.menu.repair, hint: trans.menu.repairDesc },
2730
{ value: 'docs', label: trans.menu.docs, hint: trans.menu.docsDesc },
31+
{ value: 'status', label: trans.menu.status, hint: trans.menu.statusDesc },
2832
{ value: 'links', label: trans.menu.links, hint: trans.menu.linksDesc },
2933
{ value: 'about', label: trans.menu.about, hint: trans.menu.aboutDesc },
34+
{ value: 'theme', label: trans.menu.theme, hint: trans.menu.themeDesc },
3035
{ value: 'language', label: trans.menu.language, hint: trans.menu.languageDesc },
3136
];
3237
}
@@ -64,11 +69,13 @@ export async function runMenuAction(action: MenuAction): Promise<void> {
6469
case 'events': await showCalendar(); break;
6570
case 'repair': await openRepairService(); break;
6671
case 'docs': await showDocsMenu(); break;
72+
case 'status': await showServiceStatus(); break;
6773
case 'links': await showLinksMenu(); break;
6874
case 'website': await openHomepage(); break;
6975
case 'github': await openGithub(); break;
7076
case 'roadmap': await openRoadmap(); break;
7177
case 'about': showAbout(); break;
78+
case 'theme': await showThemeMenu(); break;
7279
case 'language': await showLanguageMenu(); break;
7380
}
7481
}
@@ -115,7 +122,7 @@ function showAbout(): void {
115122
link(trans.about.website, URLS.homepage),
116123
link(trans.about.email, URLS.email),
117124
'',
118-
row(trans.about.license, 'MIT · Author: m1ngsama'),
125+
row(trans.about.license, `MIT ${pickIcon('·', '|')} Author: m1ngsama`),
119126
].join('\n');
120127

121128
note(content, trans.about.title);
@@ -131,17 +138,21 @@ async function showLanguageMenu(): Promise<void> {
131138
const language = await select<Language>({
132139
message: trans.language.selectLanguage,
133140
options: [
134-
{ value: 'zh' as Language, label: trans.language.zh, hint: currentLang === 'zh' ? '✓ 当前' : undefined },
135-
{ value: 'en' as Language, label: trans.language.en, hint: currentLang === 'en' ? '✓ current' : undefined },
141+
{ value: 'zh' as Language, label: trans.language.zh, hint: currentLang === 'zh' ? `${pickIcon('✓', '*')} current` : undefined },
142+
{ value: 'en' as Language, label: trans.language.en, hint: currentLang === 'en' ? `${pickIcon('✓', '*')} current` : undefined },
136143
],
137144
initialValue: currentLang,
138145
});
139146

140147
if (isCancel(language)) return;
141148

142149
if (language !== currentLang) {
143-
setLanguage(language);
150+
const persisted = setLanguage(language);
144151
clearTranslationCache();
145-
success(t().language.changed);
152+
if (persisted) {
153+
success(t().language.changed);
154+
} else {
155+
warning(t().language.changedSessionOnly);
156+
}
146157
}
147158
}

src/core/ui.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { log, spinner as clackSpinner } from '@clack/prompts';
77
import chalk from 'chalk';
8+
import { pickIcon } from './icons.js';
89

910
/**
1011
* Display success message
@@ -39,7 +40,8 @@ export function warning(msg: string): void {
3940
*/
4041
export function printDivider(): void {
4142
const terminalWidth = process.stdout.columns || 80;
42-
console.log(chalk.dim('─'.repeat(Math.min(terminalWidth, 80))));
43+
const dividerChar = pickIcon('─', '-');
44+
console.log(chalk.dim(dividerChar.repeat(Math.min(terminalWidth, 80))));
4345
}
4446

4547
/**

0 commit comments

Comments
 (0)