From cbf697a012506ecb136e740114c83fe1deb9cfe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:31:58 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(server-shell-setup):=20roo?= =?UTF-8?q?t/user=20password=20options=20and=20Ubuntu=20e2e=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --root-password / --user-password / --password (env: SETUP_ROOT_PASSWORD, SETUP_USER_PASSWORD, SETUP_PASSWORD). Before installing, detect whether root has a password; prompt on a TTY, or in unattended runs set it to the user password. Passwords go to chpasswd on stdin and are never logged. - nushell, helix and starship fall back to upstream releases where the distro repos lack them (e.g. Ubuntu 24.04), so `all` works on stock Ubuntu. - Volta PATH is now added to bash and nushell configs, not only fish. - Vitest suites: dry-run unit tests, plus an e2e run in a new ubuntu-latest workflow that installs `all` and checks every tool from bash, fish and nu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ENhhsRmri8bpyWB5yLeSkK --- .github/workflows/server-shell-setup.yml | 40 ++ packages/server-shell-setup/README.md | 25 +- packages/server-shell-setup/install-shell.sh | 350 +++++++++++++++++- packages/server-shell-setup/test/helpers.js | 16 + .../test/install-shell.e2e.test.js | 113 ++++++ .../test/install-shell.test.js | 135 +++++++ packages/server-shell-setup/vitest.config.js | 10 + skills/server-shell-setup/SKILL.md | 10 +- 8 files changed, 677 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/server-shell-setup.yml create mode 100644 packages/server-shell-setup/test/helpers.js create mode 100644 packages/server-shell-setup/test/install-shell.e2e.test.js create mode 100644 packages/server-shell-setup/test/install-shell.test.js create mode 100644 packages/server-shell-setup/vitest.config.js diff --git a/.github/workflows/server-shell-setup.yml b/.github/workflows/server-shell-setup.yml new file mode 100644 index 00000000..10f182db --- /dev/null +++ b/.github/workflows/server-shell-setup.yml @@ -0,0 +1,40 @@ +# server-shell-setup is plain bash with no package.json, so it is outside the +# turbo test matrix. This job runs its Vitest suites on a stock Ubuntu runner: +# first the dry-run tests, then a real `--components all` install followed by +# checks from inside bash, fish and nushell that everything landed. + +name: server-shell-setup + +on: + pull_request: + paths: + - "packages/server-shell-setup/**" + - ".github/workflows/server-shell-setup.yml" + push: + branches: [master] + paths: + - "packages/server-shell-setup/**" + - ".github/workflows/server-shell-setup.yml" + workflow_dispatch: + +jobs: + install-shell: + runs-on: ubuntu-latest + timeout-minutes: 60 + defaults: + run: + working-directory: packages/server-shell-setup + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Unit tests (dry run, no system changes) + run: npx --yes vitest@3 run test/install-shell.test.js + + - name: End-to-end install and verify every shell + env: + SERVER_SHELL_SETUP_E2E: "1" + run: npx --yes vitest@3 run test/install-shell.e2e.test.js diff --git a/packages/server-shell-setup/README.md b/packages/server-shell-setup/README.md index 165cf3db..e17d688f 100644 --- a/packages/server-shell-setup/README.md +++ b/packages/server-shell-setup/README.md @@ -43,12 +43,29 @@ One-command setup for a modern dev environment: `fish`, `nvim`, `nushell`, `bun` ## Install -On a fresh server, you may first need to set passwords: +On a fresh server, the installer checks whether root has a password before it +installs anything. If root has none, it prompts you to set one on a terminal; +in an unattended run it sets root to the user password you pass in. The root +and user password can be the same: ```bash -sudo passwd # set root password -sudo passwd $USER # set user password +# One password for both root and your user (env var keeps it out of `ps`) +SETUP_PASSWORD='...' bash install-shell.sh --components all --yes + +# Or separately +bash install-shell.sh --root-password '...' --user-password '...' --components all --yes + +# Only set passwords, install nothing +SETUP_PASSWORD='...' bash install-shell.sh --yes ``` +| Option | Env var | Effect | +|--------|---------|--------| +| `--root-password PW` | `SETUP_ROOT_PASSWORD` | Always sets root's password | +| `--user-password PW` | `SETUP_USER_PASSWORD` | Sets your user's password, and root's when root has none | +| `--password PW` | `SETUP_PASSWORD` | Same value for both | + +An existing root password is only replaced when `--root-password`/`--password` is given. + **Interactive menu** — pick what to install: ```bash wget -qO- tinyurl.com/shellsetup | bash @@ -518,7 +535,7 @@ It finishes by syncing and dropping the kernel page cache. The Docker prune dele | Symptom | Fix | |---------|-----| -| Installer aborts immediately on a fresh server | Set passwords first (`sudo passwd`, `sudo passwd $USER`); many images ship with none, which makes `sudo` fail confusingly | +| Installer aborts immediately on a fresh server | Many images ship with no passwords, which makes `sudo` fail confusingly. Re-run with `SETUP_PASSWORD=... ` (or `--password`) so the installer sets them first | | Piped install shows the menu instead of running unattended | You dropped `-s --` — it's `bash -s -- all`, not `bash all` | | Shell is still bash after login | `chsh` may have failed silently; run `chsh -s $(which fish)` and open a new session | | `Invalid component: ` | Check the name against the valid list above; the script exits rather than guessing | diff --git a/packages/server-shell-setup/install-shell.sh b/packages/server-shell-setup/install-shell.sh index 62e8c2ff..de28dbd5 100644 --- a/packages/server-shell-setup/install-shell.sh +++ b/packages/server-shell-setup/install-shell.sh @@ -27,6 +27,10 @@ # # See exactly what would change without changing anything: # bash install-shell.sh --components all --dry-run # +# # Fresh server: set root + user passwords (one value for both) unattended. +# # Prefer the environment variable: command-line arguments are visible in ps. +# SETUP_PASSWORD='...' bash install-shell.sh --components all --yes +# # Notes: # - This script intentionally keeps the useful aliases/functions from the # original setup: `in`, `e`, `del`, `search`, `killport`, `setup`, and @@ -35,6 +39,9 @@ # execution under ~/.local/state/dev-shell-setup/backups/. # - Passwordless sudo, SSH password authentication, Docker rootless mode, # and setting Fish as the login shell require explicit component selection. +# - Before installing anything, the script checks whether root has a +# password. If not, it prompts for one on a terminal, or in unattended runs +# sets it to the user password given with --user-password/--password. # - No separate scripts are required; all functionality lives in this file. # # License: MIT @@ -65,6 +72,11 @@ ENABLE_NOPASSWD_SUDO=0 ENABLE_SSH_PASSWORD=0 DOCKER_MODE="none" # none | rootless +# Root and user passwords. Environment variables keep the values out of the +# process list; the matching flags override them. SETUP_PASSWORD sets both. +ROOT_PASSWORD="${SETUP_ROOT_PASSWORD:-${SETUP_PASSWORD:-}}" +USER_PASSWORD="${SETUP_USER_PASSWORD:-${SETUP_PASSWORD:-}}" + # Array of selected components. Bash arrays require Bash, hence the shebang. COMPONENTS=() PLATFORM="" @@ -398,16 +410,137 @@ install_packages() { esac } +# Whether the configured repositories offer a package at all. Debian/Ubuntu and +# Fedora/RHEL lack some tools (nushell, helix, starship on Ubuntu 24.04), which +# then come from the upstream release instead. +package_available() { + local package=$1 + case "$PACKAGE_MANAGER" in + apt) [[ -n "$(apt-cache policy "$package" 2>/dev/null | awk '/Candidate:/ && $2 != "(none)" { print $2 }')" ]] ;; + dnf) dnf -q info "$package" >/dev/null 2>&1 ;; + *) return 0 ;; + esac +} + +# Resolve a GitHub project's latest release tag from the /releases/latest +# redirect, which avoids the rate-limited REST API. +github_latest_tag() { + local repository=$1 + local url + url=$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/${repository}/releases/latest") + printf '%s\n' "${url##*/tag/}" +} + +release_arch() { + case "$(uname -m)" in + x86_64|amd64) printf 'x86_64\n' ;; + aarch64|arm64) printf 'aarch64\n' ;; + *) die "No prebuilt release is available for architecture: $(uname -m)." ;; + esac +} + +release_libc() { + if [[ "$PLATFORM" == "alpine" ]]; then + printf 'musl\n' + else + printf 'gnu\n' + fi +} + +# Download and unpack an archive into a new temporary directory, printed on +# stdout. The caller installs from it and removes it. +download_release() { + local url=$1 + local workdir + workdir=$(mktemp -d) + curl -fsSL -o "$workdir/archive" "$url" + case "$url" in + *.zip) unzip -q "$workdir/archive" -d "$workdir" ;; + *.tar.gz|*.tgz) tar -xzf "$workdir/archive" -C "$workdir" ;; + *.tar.xz) tar -xJf "$workdir/archive" -C "$workdir" ;; + *) die "Unknown archive type: ${url}" ;; + esac + rm -f -- "$workdir/archive" + printf '%s\n' "$workdir" +} + +install_starship_release() { + have starship && { log "Already installed: starship"; return 0; } + local target + target="starship-$(release_arch)-unknown-linux-musl" + local url="https://github.com/starship/starship/releases/latest/download/${target}.tar.gz" + + if (( DRY_RUN )); then + printf '%b+%b download %q and install starship to /usr/local/bin\n' "$YELLOW" "$NC" "$url" + return 0 + fi + + local workdir + workdir=$(download_release "$url") + sudo install -m 0755 "$workdir/starship" /usr/local/bin/starship + rm -rf -- "${workdir:?}" +} + +install_nushell_release() { + have nu && { log "Already installed: nu"; return 0; } + + if (( DRY_RUN )); then + printf '%b+%b download the latest nushell release and install nu to /usr/local/bin\n' "$YELLOW" "$NC" + return 0 + fi + + local tag target url workdir + tag=$(github_latest_tag nushell/nushell) + target="nu-${tag}-$(release_arch)-unknown-linux-$(release_libc)" + url="https://github.com/nushell/nushell/releases/download/${tag}/${target}.tar.gz" + workdir=$(download_release "$url") + sudo install -m 0755 "$workdir/$target/nu" /usr/local/bin/nu + rm -rf -- "${workdir:?}" +} + +# Helix finds its runtime/ directory next to the real executable, so the whole +# release lives in /opt/helix and /usr/local/bin/hx is a symlink into it. +install_helix_release() { + have hx && { log "Already installed: hx"; return 0; } + + if (( DRY_RUN )); then + printf '%b+%b download the latest helix release into /opt/helix and link /usr/local/bin/hx\n' "$YELLOW" "$NC" + return 0 + fi + + local tag target url workdir + tag=$(github_latest_tag helix-editor/helix) + target="helix-${tag}-$(release_arch)-linux" + url="https://github.com/helix-editor/helix/releases/download/${tag}/${target}.tar.xz" + workdir=$(download_release "$url") + sudo rm -rf /opt/helix + sudo mv "$workdir/$target" /opt/helix + sudo ln -sf /opt/helix/hx /usr/local/bin/hx + rm -rf -- "${workdir:?}" +} + +# Install from the distro when it has the package, else from the release. +install_package_or_release() { + local package=$1 + local release_installer=$2 + if package_is_installed "$package" || package_available "$package"; then + install_packages "$package" + else + log "${package} is not in the ${PACKAGE_MANAGER} repositories; installing the upstream release." + "$release_installer" + fi +} + install_base_dependencies() { header "Installing base dependencies" update_package_index case "$PLATFORM" in debian) - install_packages git gh wget curl fzf ripgrep jq unzip python3 python3-pip util-linux lsof + install_packages git gh wget curl fzf ripgrep jq unzip python3 python3-pip util-linux lsof xz-utils ;; fedora) - install_packages git gh wget curl fzf ripgrep jq unzip python3 python3-pip util-linux lsof + install_packages git gh wget curl fzf ripgrep jq unzip python3 python3-pip util-linux lsof xz ;; arch) install_packages git github-cli wget curl fzf ripgrep jq unzip python python-pip lsof @@ -635,11 +768,8 @@ install_nushell() { # Prefer the OS package manager rather than npm. The official Nu installation # documentation supports packages/releases and the npm route lacks plugins. case "$PLATFORM" in - debian) - install_packages nushell - ;; - fedora) - install_packages nushell + debian|fedora) + install_package_or_release nushell install_nushell_release ;; arch) install_packages nushell @@ -707,8 +837,7 @@ install_helix() { header "Installing Helix editor" case "$PLATFORM" in - debian) install_packages helix ;; - fedora) install_packages helix ;; + debian|fedora) install_package_or_release helix install_helix_release ;; arch) install_packages helix ;; alpine) install_packages helix ;; macos) install_packages helix ;; @@ -831,7 +960,11 @@ install_node() { run "$volta_bin" install pnpm yarn # Fish does not source ~/.profile, so explicitly add Volta's bin directory. + append_managed_block "$HOME/.bashrc" "volta-path" 'export VOLTA_HOME="$HOME/.volta" +export PATH="$VOLTA_HOME/bin:$PATH"' append_managed_block "$CONFIG_DIR/fish/config.fish" "volta-path" 'fish_add_path -g $HOME/.volta/bin' + append_managed_block "$CONFIG_DIR/nushell/config.nu" "volta-path" '$env.VOLTA_HOME = ($env.HOME | path join ".volta") +$env.PATH = ($env.PATH | prepend ($env.VOLTA_HOME | path join "bin"))' success "Node.js ${NODE_VERSION}, pnpm, and yarn installed through Volta" } @@ -931,8 +1064,7 @@ install_starship() { header "Installing Starship prompt" case "$PLATFORM" in - debian) install_packages starship ;; - fedora) install_packages starship ;; + debian|fedora) install_package_or_release starship install_starship_release ;; arch) install_packages starship ;; alpine) install_packages starship ;; macos) install_packages starship ;; @@ -1062,6 +1194,152 @@ enable_ssh_with_password() { success "SSH password authentication enabled; SSH configuration backup: ${backup}" } +# ----------------------------------------------------------------------------- +# Root and user passwords +# ----------------------------------------------------------------------------- +# Run a command as root: directly when already root (fresh images often lack +# sudo), otherwise through sudo. +as_root() { + if (( EUID == 0 )); then + "$@" + else + have sudo || die "sudo is required to manage passwords as a non-root user." + sudo "$@" + fi +} + +# True when a terminal can be prompted, even when the script itself arrived on +# stdin through `curl ... | bash`. +has_tty() { + [[ -t 0 ]] || { : /dev/null +} + +validate_password() { + local label=$1 + local password=$2 + [[ -n "$password" ]] || die "${label} must not be empty." + [[ "$password" != *$'\n'* && "$password" != *$'\r'* ]] || die "${label} must not contain a line break." +} + +# Prints set, unset, or unknown. An empty field or one made only of `!`/`*` +# means no usable password; `!` followed by a hash is a locked password that +# still exists, so it counts as set and is never overwritten implicitly. +# DEV_SETUP_SHADOW_FILE exists for the test suite; it is only ever read. +root_password_status() { + local shadow_file=${DEV_SETUP_SHADOW_FILE:-/etc/shadow} + if ! as_root test -r "$shadow_file" 2>/dev/null; then + printf 'unknown\n' + return 0 + fi + + local hash + hash=$(as_root awk -F: '$1 == "root" { print $2; exit }' "$shadow_file") + if [[ -z "$hash" || "$hash" =~ ^[!*]*$ ]]; then + printf 'unset\n' + else + printf 'set\n' + fi +} + +# Reads a new password twice from the terminal into PROMPTED_PASSWORD. A blank +# first entry means skip and leaves PROMPTED_PASSWORD empty. +PROMPTED_PASSWORD="" +prompt_new_password() { + local label=$1 + local first second + PROMPTED_PASSWORD="" + + while true; do + IFS= read -r -s -p "New ${label} password (leave blank to skip): " first &2 + [[ -n "$first" ]] || return 0 + IFS= read -r -s -p "Retype ${label} password: " second &2 + if [[ "$first" == "$second" ]]; then + PROMPTED_PASSWORD=$first + return 0 + fi + warn "Passwords did not match; try again." + done +} + +# The password is piped to chpasswd, never passed as an argument or logged. +set_account_password() { + local account=$1 + local password=$2 + + if (( DRY_RUN )); then + printf '%b+%b set password for %q with chpasswd (value not shown)\n' "$YELLOW" "$NC" "$account" + return 0 + fi + + printf '%s:%s\n' "$account" "$password" | as_root chpasswd + success "Password set for ${account}" +} + +configure_passwords() { + if [[ "$PLATFORM" == "macos" || "$PLATFORM" == "termux" ]]; then + if [[ -n "$ROOT_PASSWORD" || -n "$USER_PASSWORD" ]]; then + warn "Password options are not supported on ${PLATFORM}; ignoring them." + fi + ROOT_PASSWORD="" + USER_PASSWORD="" + return 0 + fi + + if [[ -n "$ROOT_PASSWORD" ]]; then + validate_password "The root password" "$ROOT_PASSWORD" + fi + if [[ -n "$USER_PASSWORD" ]]; then + validate_password "The user password" "$USER_PASSWORD" + fi + + # Under `sudo bash install-shell.sh` the account to configure is the invoking + # user, not root. + local target_user=${SUDO_USER:-$(id -un)} + if [[ "$target_user" == "root" && -z "$ROOT_PASSWORD" ]]; then + # Root is the only account here, so the user password is root's password. + ROOT_PASSWORD=$USER_PASSWORD + fi + + header "Checking root and user passwords" + + local root_status + root_status=$(root_password_status) + + if [[ -n "$ROOT_PASSWORD" ]]; then + set_account_password root "$ROOT_PASSWORD" + elif [[ "$root_status" == "set" ]]; then + log "Root already has a password." + elif [[ "$root_status" == "unknown" ]]; then + warn "Could not read /etc/shadow to check the root password; skipping. Pass --root-password to set it anyway." + elif [[ -n "$USER_PASSWORD" ]]; then + log "Root has no password; setting it to the user password." + set_account_password root "$USER_PASSWORD" + elif (( ! ASSUME_YES && ! DRY_RUN )) && has_tty; then + warn "Root has no password set." + prompt_new_password root + if [[ -n "$PROMPTED_PASSWORD" ]]; then + set_account_password root "$PROMPTED_PASSWORD" + else + log "Leaving the root password unset." + fi + PROMPTED_PASSWORD="" + elif (( DRY_RUN )); then + log "Dry run: root has no password; a real run would prompt for one or use --user-password." + else + warn "Root has no password and no password was given. Re-run with --password or SETUP_PASSWORD to set it." + fi + + if [[ -n "$USER_PASSWORD" && "$target_user" != "root" ]]; then + set_account_password "$target_user" "$USER_PASSWORD" + fi + + # Drop the secrets as soon as they have been applied. + ROOT_PASSWORD="" + USER_PASSWORD="" +} + # ----------------------------------------------------------------------------- # Component selection, menu, and argument parsing # ----------------------------------------------------------------------------- @@ -1073,16 +1351,29 @@ Options: --components LIST Comma-separated list: fish,nushell,nvim,helix,yazi,node,bun,pacstall,docker,starship,systeminfo,code,sudo,ssh,all --node-version VERSION Node major/version for Volta (default: ${NODE_VERSION}) --set-fish-default-shell Ask to make Fish the login shell after installation + --root-password PASSWORD Set the root password (env: SETUP_ROOT_PASSWORD) + --user-password PASSWORD Set the current user's password; also used for root + when root has none (env: SETUP_USER_PASSWORD) + --password PASSWORD Use one password for both root and the user + (env: SETUP_PASSWORD) --yes, -y Accept confirmation prompts --dry-run Print commands and planned configuration changes only --no-upgrade Reserved compatibility flag; package upgrades are already avoided except Arch's required synchronized update --help, -h Show this help text +Passwords: + Before installing, the script checks whether root has a password. If it has + none, it prompts on a terminal, or in unattended runs (--yes, no terminal) + sets it to the user password. Prefer the SETUP_* environment variables: + command-line arguments are visible to other users through ps. + Passwords can be the only option given, to set them without installing. + Examples: ${SCRIPT_NAME} ${SCRIPT_NAME} --components fish,node,nvim,starship --yes ${SCRIPT_NAME} --components docker --dry-run ${SCRIPT_NAME} --components fish --set-fish-default-shell + SETUP_PASSWORD='...' ${SCRIPT_NAME} --components all --yes EOF } @@ -1176,6 +1467,22 @@ parse_args() { SET_FISH_DEFAULT_SHELL=1 shift ;; + --root-password) + (($# >= 2)) || die "--root-password needs a value." + ROOT_PASSWORD=$2 + shift 2 + ;; + --user-password) + (($# >= 2)) || die "--user-password needs a value." + USER_PASSWORD=$2 + shift 2 + ;; + --password) + (($# >= 2)) || die "--password needs a value." + ROOT_PASSWORD=$2 + USER_PASSWORD=$2 + shift 2 + ;; --yes|-y) ASSUME_YES=1 shift @@ -1310,12 +1617,27 @@ install_components() { main() { detect_platform - if (($# == 0)); then - choose_interactively - else + if (($# > 0)); then parse_args "$@" fi + local passwords_given=0 + if [[ -n "$ROOT_PASSWORD" || -n "$USER_PASSWORD" ]]; then + passwords_given=1 + fi + + # Passwords come first: a fresh server without them makes sudo fail later. + configure_passwords + + if ((${#COMPONENTS[@]} == 0)); then + if (($# == 0)); then + choose_interactively + elif (( passwords_given )); then + success "Passwords configured; no components were selected." + return 0 + fi + fi + install_components } diff --git a/packages/server-shell-setup/test/helpers.js b/packages/server-shell-setup/test/helpers.js new file mode 100644 index 00000000..47a9954f --- /dev/null +++ b/packages/server-shell-setup/test/helpers.js @@ -0,0 +1,16 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +export const SCRIPT = fileURLToPath(new URL('../install-shell.sh', import.meta.url)) + +/** Run a command and return { status, stdout, stderr, output }. */ +export function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: 'utf8', ...options }) + if (result.error) throw result.error + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + output: `${result.stdout}\n${result.stderr}`, + } +} diff --git a/packages/server-shell-setup/test/install-shell.e2e.test.js b/packages/server-shell-setup/test/install-shell.e2e.test.js new file mode 100644 index 00000000..ed66f861 --- /dev/null +++ b/packages/server-shell-setup/test/install-shell.e2e.test.js @@ -0,0 +1,113 @@ +// Real install of every `all` component, then proof from inside each shell +// that the tools and helper functions are reachable. It installs system +// packages and sets the root and user passwords, so it only runs when +// SERVER_SHELL_SETUP_E2E=1 — on a throwaway GitHub Actions Ubuntu runner. +import { randomBytes } from 'node:crypto' +import { homedir, userInfo } from 'node:os' +import path from 'node:path' +import { beforeAll, describe, expect, it } from 'vitest' +import { run, SCRIPT } from './helpers.js' + +const enabled = process.env.SERVER_SHELL_SETUP_E2E === '1' +const HOME = homedir() +const USER = userInfo().username +const NU_CONFIG = path.join(HOME, '.config/nushell/config.nu') + +// A login-like environment: nothing inherited from the CI runner's PATH, so a +// tool only counts as installed if the shell's own config puts it on PATH. +const cleanEnv = { + HOME, + USER, + TERM: 'dumb', + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', +} + +const shells = { + bash: (cmd) => run('bash', ['-ic', cmd], { env: cleanEnv }), + fish: (cmd) => run('fish', ['-c', cmd], { env: cleanEnv }), + nu: (cmd) => run('nu', ['--config', NU_CONFIG, '-c', cmd], { env: cleanEnv }), +} + +// Commands every shell must resolve after `--components all`. +const TOOLS = [ + 'git', 'gh', 'curl', 'wget', 'fzf', 'rg', 'jq', 'lsof', + 'fish', 'nu', 'nvim', 'hx', 'yazi', 'starship', + 'node', 'npm', 'pnpm', 'yarn', 'bun', 'pacstall', +] + +// apt packages the installer itself asks for on Debian/Ubuntu. +const APT_PACKAGES = [ + 'git', 'gh', 'wget', 'curl', 'fzf', 'ripgrep', 'jq', 'unzip', 'python3', + 'python3-pip', 'util-linux', 'lsof', 'xz-utils', 'fish', 'neovim', 'file', +] + +describe.runIf(enabled)('install-shell.sh --components all (e2e)', () => { + let install + + beforeAll(() => { + install = run('bash', [SCRIPT, '--components', 'all', '--yes'], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, SETUP_PASSWORD: randomBytes(12).toString('hex') }, + }) + process.stdout.write(install.output) + }) + + it('finishes successfully', () => { + expect(install.status).toBe(0) + expect(install.stdout).toContain('Installation Complete') + }) + + it.each(APT_PACKAGES)('apt package %s is installed', (pkg) => { + expect(run('dpkg', ['-s', pkg]).status).toBe(0) + }) + + it.each(['root', USER])('%s has a usable password', (account) => { + const { stdout } = run('sudo', ['passwd', '-S', account]) + expect(stdout.trim().split(/\s+/)[1]).toBe('P') + }) + + for (const [shell, exec] of Object.entries(shells)) { + describe(shell, () => { + it.each(TOOLS)(`resolves %s`, (tool) => { + const cmd = shell === 'nu' ? `which ${tool} | length` : `command -v ${tool}` + const { status, stdout } = exec(cmd) + expect(status, `${shell} could not find ${tool}`).toBe(0) + if (shell === 'nu') expect(stdout.trim()).toBe('1') + }) + + it('runs node 22 through Volta', () => { + const { status, stdout } = exec(`node -p "process.version + ' ' + process.execPath"`) + expect(status).toBe(0) + expect(stdout).toMatch(/^v22\./) + expect(stdout).toContain('.volta') + }) + + it('runs bun and starship', () => { + expect(exec('bun --version').status).toBe(0) + expect(exec('starship --version').status).toBe(0) + }) + }) + } + + it('defines the fish helper functions', () => { + const fns = ['in', 'e', 'del', 'search', 'killport', 'service_manager', 'setup', 'systeminfo', 'y'] + for (const fn of fns) { + expect(shells.fish(`functions -q ${fn}`).status, `fish function ${fn}`).toBe(0) + } + }) + + it('defines the bash helper functions', () => { + expect(shells.bash('type y && type systeminfo').status).toBe(0) + }) + + it('defines the nushell helper commands', () => { + const { stdout } = shells.nu('scope commands | where name in [y systeminfo] | length') + expect(stdout.trim()).toBe('2') + }) + + it('verifies each tool reports a version', () => { + for (const cmd of ['fish --version', 'nu --version', 'nvim --version', 'hx --version', 'yazi --version']) { + expect(shells.bash(cmd).status, cmd).toBe(0) + } + }) +}) diff --git a/packages/server-shell-setup/test/install-shell.test.js b/packages/server-shell-setup/test/install-shell.test.js new file mode 100644 index 00000000..ee6fd949 --- /dev/null +++ b/packages/server-shell-setup/test/install-shell.test.js @@ -0,0 +1,135 @@ +// Fast checks that never change the machine: syntax, argument parsing, and the +// root/user password logic, all in --dry-run against a fixture shadow file. +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir, userInfo } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { run, SCRIPT } from './helpers.js' + +const SECRET = 'fixture-Pa55word' +const ROOT_UNSET = 'root:*:19000:0:99999:7:::\n' +const ROOT_LOCKED_EMPTY = 'root:!:19000:0:99999:7:::\n' +const ROOT_SET = 'root:$6$salt$hash:19000:0:99999:7:::\n' + +let home + +function installer(args, { shadow, env = {} } = {}) { + const shadowFile = path.join(home, 'shadow') + if (shadow !== undefined) writeFileSync(shadowFile, shadow) + return run('bash', [SCRIPT, ...args], { + // No terminal and an isolated HOME: this is the unattended path. + stdio: ['ignore', 'pipe', 'pipe'], + env: { + PATH: process.env.PATH, + HOME: home, + USER: userInfo().username, + ...(shadow !== undefined ? { DEV_SETUP_SHADOW_FILE: shadowFile } : {}), + ...env, + }, + }) +} + +beforeEach(() => { + home = mkdtempSync(path.join(tmpdir(), 'shell-setup-')) +}) + +afterEach(() => { + rmSync(home, { recursive: true, force: true }) +}) + +describe('install-shell.sh', () => { + it('parses as bash', () => { + expect(run('bash', ['-n', SCRIPT]).status).toBe(0) + }) + + it('documents the password options', () => { + const { status, stdout } = installer(['--help']) + expect(status).toBe(0) + for (const flag of ['--root-password', '--user-password', '--password', 'SETUP_PASSWORD']) { + expect(stdout).toContain(flag) + } + }) + + it.each(['--root-password', '--user-password', '--password'])('%s requires a value', (flag) => { + const { status, stderr } = installer([flag]) + expect(status).toBe(1) + expect(stderr).toContain(`${flag} needs a value`) + }) + + it('rejects unknown components', () => { + const { status, stderr } = installer(['--components', 'nope', '--dry-run']) + expect(status).toBe(1) + expect(stderr).toContain('Unknown component: nope') + }) +}) + +describe.skipIf(process.platform !== 'linux')('passwords (dry run)', () => { + const isRoot = userInfo().username === 'root' + + it('sets an unset root password to the user password when automated', () => { + const { status, output } = installer( + ['--user-password', SECRET, '--dry-run', '--yes'], + { shadow: ROOT_UNSET }, + ) + expect(status).toBe(0) + if (!isRoot) expect(output).toContain('Root has no password; setting it to the user password.') + expect(output).toContain('set password for root') + expect(output).toContain('Passwords configured; no components were selected.') + expect(output).not.toContain(SECRET) + }) + + it('treats a bare "!" as no password', () => { + const { output } = installer(['--user-password', SECRET, '--dry-run'], { shadow: ROOT_LOCKED_EMPTY }) + expect(output).toContain('set password for root') + }) + + it.skipIf(isRoot)('sets the user password and leaves an existing root password alone', () => { + const { status, output } = installer(['--user-password', SECRET, '--dry-run'], { shadow: ROOT_SET }) + expect(status).toBe(0) + expect(output).toContain('Root already has a password.') + expect(output).not.toContain('set password for root') + expect(output).toContain(`set password for ${userInfo().username}`) + expect(output).not.toContain(SECRET) + }) + + it('overwrites root when --root-password is explicit', () => { + const { output } = installer(['--root-password', SECRET, '--dry-run'], { shadow: ROOT_SET }) + expect(output).toContain('set password for root') + expect(output).not.toContain(SECRET) + }) + + it('--password sets both accounts', () => { + const { output } = installer(['--password', SECRET, '--dry-run'], { shadow: ROOT_UNSET }) + expect(output).toContain('set password for root') + if (!isRoot) expect(output).toContain(`set password for ${userInfo().username}`) + }) + + it('reads SETUP_PASSWORD from the environment', () => { + const { output } = installer(['--dry-run', '--components', 'fish'], { + shadow: ROOT_UNSET, + env: { SETUP_PASSWORD: SECRET }, + }) + expect(output).toContain('set password for root') + expect(output).not.toContain(SECRET) + }) + + it('reports an unset root password when none was given', () => { + const { status, output } = installer(['--dry-run', '--components', 'fish'], { shadow: ROOT_UNSET }) + expect(status).toBe(0) + expect(output).toContain('Dry run: root has no password') + }) + + it('rejects a password containing a line break', () => { + const { status, stderr } = installer(['--password', 'a\nb', '--dry-run'], { shadow: ROOT_UNSET }) + expect(status).toBe(1) + expect(stderr).toContain('must not contain a line break') + }) + + it('plans every "all" component without changing anything', () => { + const { status, output } = installer(['--components', 'all', '--dry-run', '--yes'], { shadow: ROOT_SET }) + expect(status).toBe(0) + for (const heading of ['Fish shell', 'Nushell', 'Neovim', 'Helix', 'Yazi', 'Node.js', 'Bun', 'Starship']) { + expect(output).toContain(heading) + } + }) +}) diff --git a/packages/server-shell-setup/vitest.config.js b/packages/server-shell-setup/vitest.config.js new file mode 100644 index 00000000..822124e9 --- /dev/null +++ b/packages/server-shell-setup/vitest.config.js @@ -0,0 +1,10 @@ +// No package.json here on purpose: server-shell-setup is not a workspace, so +// run this with `npx vitest@3 run` from this directory (see the workflow). +export default { + test: { + include: ['test/**/*.test.js'], + testTimeout: 60_000, + // The E2E suite installs every component once in beforeAll. + hookTimeout: 45 * 60_000, + }, +} diff --git a/skills/server-shell-setup/SKILL.md b/skills/server-shell-setup/SKILL.md index b4369dae..ea0dd5c9 100644 --- a/skills/server-shell-setup/SKILL.md +++ b/skills/server-shell-setup/SKILL.md @@ -11,13 +11,15 @@ The shell scripts in `packages/server-shell-setup` (`install-shell.sh`, plus `ge ## Setup -On a brand-new server, set passwords first (many providers ship with none, which makes `sudo` fail in confusing ways): +On a brand-new server many providers ship with no passwords, which makes `sudo` fail in confusing ways. `install-shell.sh` checks root first: with no root password it prompts on a terminal, or in unattended runs (`--yes` / no TTY) sets root to the user password. Pass passwords as flags or, better (kept out of `ps`), env vars — root and user can share one: ```bash -sudo passwd # root -sudo passwd $USER # your user +SETUP_PASSWORD='...' bash install-shell.sh --components all --yes # both accounts +bash install-shell.sh --root-password '...' --user-password '...' --yes # separately ``` +`--root-password`/`SETUP_ROOT_PASSWORD` always sets root; `--user-password`/`SETUP_USER_PASSWORD` sets the invoking user (`$SUDO_USER` under sudo) and fills root only when root has none; `--password`/`SETUP_PASSWORD` sets both. Password flags alone, with no components, just set passwords and exit. + Then pick an install mode: ```bash @@ -64,7 +66,7 @@ The `-s --` is what forwards arguments through the pipe to bash — dropping it | --- | --- | | Arguments ignored, menu appears anyway | You piped without `-s --`. Use `bash -s -- all`. | | Menu appears but you're in CI | The interactive path needs a TTY. Always pass an explicit component list or `all` in automation. | -| `sudo: no password` / repeated prompts | Set the passwords first (above). The `sudo` component enables passwordless sudo, but it can't run before you can `sudo` at all. | +| `sudo: no password` / repeated prompts | Pass `SETUP_PASSWORD` / `--password` so the installer sets them first (above). The `sudo` component enables passwordless sudo, but it can't run before you can `sudo` at all. | | Fish installed but the shell didn't change | The default shell change needs `chsh -s $(which fish)` and a new login session; in containers there's often no login shell at all — invoke `fish` from your entrypoint. | | Docker installed but `docker ps` fails | Rootless Docker needs its user daemon running and the socket env var set — log out and back in, then check `systemctl --user status docker`. In containers without systemd, rootless mode won't come up. | | A component silently didn't install | It isn't available for the detected OS. Re-run just that component to see the output rather than scrolling the full log. | From c6faad315b4f6b66a5ca973478824b0d9d183091 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:34:16 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat(about-system):=20show=20GP?= =?UTF-8?q?U=20bench=20after=20the=20GPU,=20whole-k=20scores,=20top=20%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default line now reads cpu, bench, gpu, gpu_bench, so each Geekbench score sits next to the part it measures (gpu_bench uses src/bench/gpu-geekbench.json). - Scores round to whole thousands (21k, not 20.5k) and the rank is followed by its top percentile: "21k #68 top 7%". - The *_bench_info variants read "Geekbench 6: 21k (Rank #68, top 7%) - Intel". - Unit tests for formatBench/topPercent. Bump to 0.11.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ENhhsRmri8bpyWB5yLeSkK --- packages/about-system-info/README.md | 2 + packages/about-system-info/package.json | 2 +- .../about-system-info/src/about-system-cli.ts | 2 +- .../src/info/hardware.test.ts | 28 +++++++++++ .../about-system-info/src/info/hardware.ts | 50 +++++++++++++------ .../about-system-info/src/info/settings.ts | 15 +++++- .../src/systeminfo-types.d.ts | 18 +++---- skills/about-system/API.md | 2 +- 8 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 packages/about-system-info/src/info/hardware.test.ts diff --git a/packages/about-system-info/README.md b/packages/about-system-info/README.md index cdde3444..dbbfef91 100644 --- a/packages/about-system-info/README.md +++ b/packages/about-system-info/README.md @@ -202,7 +202,9 @@ async function getBasicInfo() { | `isp` | Internet service provider | `👮 Verizon Business` | | `os` | Operating system | `⚡ Ubuntu 22.04` | | `cpu` | CPU information | `📈 Intel Core i7-8700K` | +| `bench` | CPU Geekbench 6 multi-core, rank, top percentile | `💪 7k #612 top 62%` | | `gpu` | Graphics card | `🎮 NVIDIA GeForce RTX 3080` | +| `gpu_bench` | GPU Geekbench 6 OpenCL, rank, top percentile | `🏎️ 175k #140 top 14%` | | `disk_used` | Disk usage percentage | `📁 75%` | | `disk_size` | Disk size per real disk | `💽 256/512GB` | | `ram_used` | Memory usage | `💾 8/16GB` | diff --git a/packages/about-system-info/package.json b/packages/about-system-info/package.json index 24bbe5f7..01616a24 100644 --- a/packages/about-system-info/package.json +++ b/packages/about-system-info/package.json @@ -1,6 +1,6 @@ { "name": "about-system", - "version": "0.10.5", + "version": "0.11.0", "description": "A Node.js script to display key system information with emojis. Cross-platform support for Windows, macOS, and Linux with customizable output and caching.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/about-system-info/src/about-system-cli.ts b/packages/about-system-info/src/about-system-cli.ts index f550d87e..4767589f 100644 --- a/packages/about-system-info/src/about-system-cli.ts +++ b/packages/about-system-info/src/about-system-cli.ts @@ -478,7 +478,7 @@ Available display blocks: Basic: user, hostname, uptime, shell, os, kernel, device Resources: disk_used, ram_used, memory_available, swap_used, top_process Network: ip, iplocal, city, domain, isp, network_interfaces - Hardware: cpu, gpu, temperature, battery, screen_resolution + Hardware: cpu, bench, gpu, gpu_bench, temperature, battery, screen_resolution System: load_average, users_logged_in, mount_points, services_running Tools: pacman, ports, containers `); diff --git a/packages/about-system-info/src/info/hardware.test.ts b/packages/about-system-info/src/info/hardware.test.ts new file mode 100644 index 00000000..5741bfb6 --- /dev/null +++ b/packages/about-system-info/src/info/hardware.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { formatBench, topPercent } from "./hardware"; + +describe("topPercent", () => { + it("rounds up so the fastest entry reads top 1%", () => { + expect(topPercent(1, 1000)).toBe(1); + expect(topPercent(68, 1000)).toBe(7); + expect(topPercent(50, 1000)).toBe(5); + }); + + it("stays within 1..100", () => { + expect(topPercent(1000, 1000)).toBe(100); + expect(topPercent(1200, 1000)).toBe(100); + expect(topPercent(5, 0)).toBe(100); + }); +}); + +describe("formatBench", () => { + it("rounds the score to whole thousands and appends the percentile", () => { + expect(formatBench(37967, 1, 1000)).toBe("38k #1 top 1%"); + expect(formatBench(20512, 68, 1000)).toBe("21k #68 top 7%"); + expect(formatBench(386779, 5, 1026)).toBe("387k #5 top 1%"); + }); + + it("never prints a decimal in the score", () => { + expect(formatBench(19249, 91, 1000)).not.toMatch(/\d\.\d/); + }); +}); diff --git a/packages/about-system-info/src/info/hardware.ts b/packages/about-system-info/src/info/hardware.ts index 65c24d22..3e54759d 100644 --- a/packages/about-system-info/src/info/hardware.ts +++ b/packages/about-system-info/src/info/hardware.ts @@ -11,6 +11,30 @@ import { execCommand } from "../utils/command"; import { getCachedValue, setCachedValue } from "../cache/cache"; import Fuse from "fuse.js"; +/** + * Formats a Geekbench score in whole thousands with its rank and how far up + * the table that rank sits. + * @param score - Raw Geekbench score + * @param rank - Rank in the benchmark table (1 = fastest) + * @param total - Number of entries in the benchmark table + * @returns Score, rank and top percentile + * @example formatBench(20512, 68, 1500) // "21k #68 top 7%" + */ +export function formatBench(score: number, rank: number, total: number): string { + return `${Math.round(score / 1000)}k #${rank} top ${topPercent(rank, total)}%`; +} + +/** + * Percentile bucket a rank falls in, rounded up so rank 1 reads "top 1%" + * @param rank - Rank in the benchmark table (1 = fastest) + * @param total - Number of entries in the benchmark table + * @returns Whole percentage between 1 and 100 + */ +export function topPercent(rank: number, total: number): number { + if (!total || total < 1) return 100; + return Math.min(100, Math.max(1, Math.ceil((rank / total) * 100))); +} + /** * Gets CPU model name and specifications * Uses platform-specific commands (wmic/lscpu/cpuinfo) @@ -222,7 +246,7 @@ export function screen_resolution(): string { * Uses fuzzy matching to find similar CPU models * @param context - Info context with cache * @returns Benchmark info with score and rank, or empty string - * @example "37.9k #1", "20.5k #68" + * @example "38k #1 top 1%", "21k #68 top 7%" */ export function bench(context: InfoContext): string { const cached = getCachedValue(context.cache, "bench"); @@ -274,9 +298,7 @@ export function bench(context: InfoContext): string { const results = fuse.search(cpuName); if (results.length > 0) { const match = results[0].item; - const score = Math.round(match.score / 100) / 10; - const rank = match.rank; - const result = `${score}k #${rank}`; + const result = formatBench(match.score, match.rank, scores.length); setCachedValue(context.cache, "bench", result); return result; } @@ -293,7 +315,7 @@ export function bench(context: InfoContext): string { * Includes score, rank, and architecture type (ARM/Intel/AMD) * @param context - Info context with cache * @returns Detailed benchmark info with architecture, or empty string - * @example "Geekbench 6: 37.9k (Rank #1) - ARM", "Geekbench 6: 20.5k (Rank #68) - Intel" + * @example "Geekbench 6: 38k (Rank #1, top 1%) - ARM", "Geekbench 6: 21k (Rank #68, top 7%) - Intel" */ export function cpu_bench_info(context: InfoContext): string { const cached = getCachedValue(context.cache, "cpu_bench_info"); @@ -349,8 +371,9 @@ export function cpu_bench_info(context: InfoContext): string { const results = fuse.search(cpuName); if (results.length > 0) { const match = results[0].item; - const score = Math.round(match.score / 100) / 10; + const score = Math.round(match.score / 1000); const rank = match.rank; + const top = topPercent(rank, scores.length); // Detect architecture type let arch = "Unknown"; @@ -364,7 +387,7 @@ export function cpu_bench_info(context: InfoContext): string { arch = "ARM"; } - const result = `Geekbench 6: ${score}k (Rank #${rank}) - ${arch}`; + const result = `Geekbench 6: ${score}k (Rank #${rank}, top ${top}%) - ${arch}`; setCachedValue(context.cache, "cpu_bench_info", result); return result; } @@ -381,7 +404,7 @@ export function cpu_bench_info(context: InfoContext): string { * Uses fuzzy matching to find similar GPU models * @param context - Info context with cache * @returns Benchmark info with score and rank, or empty string - * @example "37.9k #1", "20.5k #68" + * @example "38k #1 top 1%", "21k #68 top 7%" */ export function gpu_bench(context: InfoContext): string { const cached = getCachedValue(context.cache, "gpu_bench"); @@ -433,9 +456,7 @@ export function gpu_bench(context: InfoContext): string { const results = fuse.search(gpuName); if (results.length > 0) { const match = results[0].item; - const score = Math.round(match.score / 100) / 10; - const rank = match.rank; - const result = `${score}k #${rank}`; + const result = formatBench(match.score, match.rank, scores.length); setCachedValue(context.cache, "gpu_bench", result); return result; } @@ -452,7 +473,7 @@ export function gpu_bench(context: InfoContext): string { * Includes score, rank, and vendor detection * @param context - Info context with cache * @returns Detailed benchmark info with vendor, or empty string - * @example "Geekbench 6: 37.9k (Rank #1) - NVIDIA", "Geekbench 6: 20.5k (Rank #68) - AMD" + * @example "Geekbench 6: 38k (Rank #1, top 1%) - NVIDIA", "Geekbench 6: 21k (Rank #68, top 7%) - AMD" */ export function gpu_bench_info(context: InfoContext): string { const cached = getCachedValue(context.cache, "gpu_bench_info"); @@ -508,8 +529,9 @@ export function gpu_bench_info(context: InfoContext): string { const results = fuse.search(gpuName); if (results.length > 0) { const match = results[0].item; - const score = Math.round(match.score / 100) / 10; + const score = Math.round(match.score / 1000); const rank = match.rank; + const top = topPercent(rank, scores.length); // Detect vendor from GPU name let vendor = "Unknown"; @@ -532,7 +554,7 @@ export function gpu_bench_info(context: InfoContext): string { vendor = "Intel"; } - const result = `Geekbench 6: ${score}k (Rank #${rank}) - ${vendor}`; + const result = `Geekbench 6: ${score}k (Rank #${rank}, top ${top}%) - ${vendor}`; setCachedValue(context.cache, "gpu_bench_info", result); return result; } diff --git a/packages/about-system-info/src/info/settings.ts b/packages/about-system-info/src/info/settings.ts index 6acf1c56..05b86b19 100644 --- a/packages/about-system-info/src/info/settings.ts +++ b/packages/about-system-info/src/info/settings.ts @@ -59,7 +59,17 @@ export const backgrounds = { // Default settings export const DEFAULT_SETTINGS = { display_order: [ - ["user", "hostname", "os", "device", "kernel", "cpu", "gpu", "bench"], + [ + "user", + "hostname", + "os", + "device", + "kernel", + "cpu", + "bench", + "gpu", + "gpu_bench", + ], [ "disk_used", "disk_size", @@ -90,6 +100,7 @@ export const DEFAULT_SETTINGS = { cpu: "orange", gpu: "yellow", bench: "red", + gpu_bench: "red", device: "yellow", kernel: "green", shell: "orange", @@ -119,6 +130,7 @@ export const DEFAULT_SETTINGS = { cpu: "📈 ", gpu: "🎮 ", bench: "💪 ", + gpu_bench: "🏎️ ", device: "💻 ", kernel: "🔧 ", shell: "🐚 ", @@ -154,6 +166,7 @@ export const DEFAULT_SETTINGS = { cpu: "CPU", gpu: "GPU", bench: "Bench", + gpu_bench: "GPU Bench", device: "Device", kernel: "Kernel", shell: "Shell", diff --git a/packages/about-system-info/src/systeminfo-types.d.ts b/packages/about-system-info/src/systeminfo-types.d.ts index 0e96b5d1..cdff71bc 100644 --- a/packages/about-system-info/src/systeminfo-types.d.ts +++ b/packages/about-system-info/src/systeminfo-types.d.ts @@ -129,8 +129,8 @@ export interface SystemInfo { * Empty string if CPU not found or benchmark data unavailable * * @example - * - "37.9k #1" (top performing CPU) - * - "20.5k #68" (mid-range CPU) + * - "38k #1 top 1%" (top performing CPU) + * - "21k #68 top 7%" (mid-range CPU) * - "" (CPU not in benchmark database) */ bench: string; @@ -142,9 +142,9 @@ export interface SystemInfo { * Empty string if CPU not found or benchmark data unavailable * * @example - * - "Geekbench 6: 37.9k (Rank #1) - ARM" (Apple M4 Ultra) - * - "Geekbench 6: 20.5k (Rank #68) - Intel" (Intel Core i9) - * - "Geekbench 6: 19.2k (Rank #91) - AMD" (AMD Ryzen 9 7950X) + * - "Geekbench 6: 38k (Rank #1, top 1%) - ARM" (Apple M4 Ultra) + * - "Geekbench 6: 21k (Rank #68, top 7%) - Intel" (Intel Core i9) + * - "Geekbench 6: 19k (Rank #91, top 10%) - AMD" (AMD Ryzen 9 7950X) * - "" (CPU not in benchmark database) */ cpu_bench_info: string; @@ -156,8 +156,8 @@ export interface SystemInfo { * Empty string if GPU not found or benchmark data unavailable * * @example - * - "37.9k #1" (top performing GPU) - * - "20.5k #68" (mid-range GPU) + * - "38k #1 top 1%" (top performing GPU) + * - "21k #68 top 7%" (mid-range GPU) * - "" (GPU not in benchmark database) */ gpu_bench: string; @@ -169,8 +169,8 @@ export interface SystemInfo { * Empty string if GPU not found or benchmark data unavailable * * @example - * - "Geekbench 6: 37.9k (Rank #1) - NVIDIA" (NVIDIA GPU) - * - "Geekbench 6: 20.5k (Rank #68) - AMD" (AMD GPU) + * - "Geekbench 6: 38k (Rank #1, top 1%) - NVIDIA" (NVIDIA GPU) + * - "Geekbench 6: 21k (Rank #68, top 7%) - AMD" (AMD GPU) * - "" (GPU not in benchmark database) */ gpu_bench_info: string; diff --git a/skills/about-system/API.md b/skills/about-system/API.md index 3723193a..37e1cb51 100644 --- a/skills/about-system/API.md +++ b/skills/about-system/API.md @@ -33,7 +33,7 @@ Exact CLI flags, block names, settings keys, and cache TTLs. | Block | Output | | --- | --- | | `user`, `hostname`, `device`, `kernel`, `os` | `👤 user`, `🏠 host`, `💻 MacBook Pro`, `🔧 5.15.0`, `⚡ Ubuntu 22.04` | -| `cpu`, `gpu`, `bench`, `cpu_bench_info`, `gpu_bench`, `gpu_bench_info` | Model strings plus Geekbench lookups from the bundled `bench/*.json` | +| `cpu`, `gpu`, `bench`, `cpu_bench_info`, `gpu_bench`, `gpu_bench_info` | Model strings plus Geekbench lookups from the bundled `bench/*.json`. `bench`/`gpu_bench` print the score in whole thousands, the rank and its top percentile (`💪 21k #68 top 7%`); the `_info` variants spell it out (`Geekbench 6: 21k (Rank #68, top 7%) - Intel`). The default line shows `cpu bench gpu gpu_bench` in that order. | | `disk_used`, `ram_used`, `memory_available`, `swap_used`, `mount_points` | `📁 75%`, `💾 8/16GB`, and related storage/memory readouts | | `top_process`, `load_average`, `uptime`, `users_logged_in` | `🔝 15% chrome`, load, `⏱️ 2d 5h 30m` | | `ip`, `iplocal`, `city`, `domain`, `isp`, `network_interfaces` | Public/local IP, geo-IP city, reverse DNS, ISP |