diff --git a/README.md b/README.md
index 1ed9cb1..785d4e4 100644
--- a/README.md
+++ b/README.md
@@ -61,13 +61,15 @@ No ncurses. No browser DOM. No React. No native addon. No network access. Ever.
## Install
The `hqtui` CLI (widget showcase, `hqtui doctor`, the emoji font installer) in
-one line. It uses bun if you have it, otherwise npm with Node 22.6+, and never
-asks for sudo:
+one line. It installs into `~/.local`, never asks for sudo, and fetches a private
+Node if this machine has no Node 22+:
```sh
curl -fsSL https://hqtui.com/install.sh | sh
```
+Run it again to update; remove with `sh ~/.local/share/hqtui/uninstall.sh`.
+
To build an app, add the library to your project:
```bash
diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx
index 2c429c5..1a2094c 100644
--- a/apps/web/app/docs/page.tsx
+++ b/apps/web/app/docs/page.tsx
@@ -175,7 +175,7 @@ npm add @profullstack/hqtui # Node 22.6+ works unchanged`} />
There is also a CLI: hqtui doctor{" "}
reports what your terminal actually supports, and{" "}
hqtui opens a built-in showcase.
- Install it in one line (bun if present, otherwise npm with Node 22.6+, no sudo):
+ Install it in one line (into ~/.local, no sudo; fetches a private Node if you have no Node 22+):
diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh
index 618629d..65374f0 100755
--- a/apps/web/public/install.sh
+++ b/apps/web/public/install.sh
@@ -1,60 +1,159 @@
#!/bin/sh
-# Install the hqtui CLI (npm: @profullstack/hqtui): the widget showcase,
-# `hqtui doctor` for terminal capabilities, and the OpenEmoji font installer.
+# hqtui installer.
#
# curl -fsSL https://hqtui.com/install.sh | sh
#
-# Uses bun if present, otherwise npm (Node >= 22.6). When the global npm
-# prefix is not writable it installs into ~/.local instead of asking for sudo.
-# Re-running it upgrades to the latest release.
-set -eu
-
-PKG="@profullstack/hqtui"
-BIN="hqtui"
-
-say() { printf '%s\n' "$*"; }
-die() { printf 'hqtui install: %s\n' "$*" >&2; exit 1; }
-have() { command -v "$1" >/dev/null 2>&1; }
-
-say "Installing hqtui (High Quality Terminal UI)..."
-
-if have bun; then
- # bun add -g writes into the nearest package.json above its global dir when
- # that dir has none (e.g. ~/package.json): give it its own first.
- BUN_GLOBAL="${BUN_INSTALL:-$HOME/.bun}/install/global"
- mkdir -p "$BUN_GLOBAL"
- [ -f "$BUN_GLOBAL/package.json" ] || printf '{}\n' > "$BUN_GLOBAL/package.json"
- bun add -g "$PKG@latest"
- BIN_DIR="${BUN_INSTALL:-$HOME/.bun}/bin"
-elif have npm && have node; then
- node -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=6)?0:1)' 2>/dev/null \
- || die "Node >= 22.6 required (found $(node -v)). Upgrade Node or install bun: https://bun.sh"
- prefix=$(npm config get prefix)
- modules="$prefix/lib/node_modules"
- [ -d "$modules" ] || modules="$prefix/lib"
- if [ -w "$modules" ] && [ -w "$prefix/bin" ]; then
- npm install -g "$PKG@latest"
- BIN_DIR="$prefix/bin"
+# Installs @profullstack/hqtui (commands: hqtui) under your home directory. No root, no system
+# package manager, no system files touched. If this machine has no Node 22+,
+# an official Node build is fetched (checksum verified) into the same private directory
+# and used from there. Run this again to update. Remove with:
+# sh ~/.local/share/hqtui/uninstall.sh
+#
+# sh -s -- --version X install a specific release
+# sh -s -- --prefix DIR install root (default: ~/.local)
+#
+# Listed in the W3BS catalog (https://w3bs.org/packages): `w3bs install hqtui` runs
+# this same script and `w3bs update` re-runs it.
+#
+# Generated from the W3BS npm CLI installer template; keep the variables below in sync
+# with package.json. Nothing runs until the last line, so a truncated download is inert.
+
+main() {
+ set -eu
+ PKG="@profullstack/hqtui"
+ TOOL="hqtui"
+ URL="https://hqtui.com/install.sh"
+ NODE_MAJOR="22"
+ PREFIX="${INSTALL_PREFIX:-$HOME/.local}"
+ VERSION=""
+ NODE_CHOICE="${INSTALL_NODE:-auto}"
+
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --version) VERSION="${2:?--version needs a value}"; shift ;;
+ --prefix) PREFIX="${2:?--prefix needs a value}"; shift ;;
+ -h | --help) echo "Usage: curl -fsSL $URL | sh [-s -- --version X --prefix DIR]"; return 0 ;;
+ *) echo "$TOOL installer: unknown option $1" >&2; return 64 ;;
+ esac
+ shift
+ done
+
+ say() { printf '%s\n' "$*"; }
+ fail() { printf '%s installer: %s\n' "$TOOL" "$*" >&2; exit 1; }
+
+ case "$(uname -s)" in
+ Linux) OS=linux ;;
+ Darwin) OS=darwin ;;
+ *) fail "unsupported operating system $(uname -s); Linux and macOS are supported" ;;
+ esac
+ case "$(uname -m)" in
+ x86_64 | amd64) ARCH=x64 ;;
+ aarch64 | arm64) ARCH=arm64 ;;
+ *) fail "unsupported CPU $(uname -m)" ;;
+ esac
+ if command -v curl >/dev/null 2>&1; then
+ fetch() { curl --proto '=https' --tlsv1.2 -fsSL "$1" -o "$2"; }
+ elif command -v wget >/dev/null 2>&1; then
+ fetch() { wget --https-only -q "$1" -O "$2"; }
else
- BIN_DIR="$HOME/.local/bin"
- say "npm prefix $prefix is not writable; installing into $HOME/.local"
- npm install -g --prefix "$HOME/.local" "$PKG@latest"
+ fail "curl or wget is required"
+ fi
+
+ SHARE="$PREFIX/share/$TOOL"
+ BIN="$PREFIX/bin"
+ mkdir -p "$SHARE" "$BIN"
+
+ node_major() { "$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0; }
+ NODE=""
+ NODE_KIND=""
+ if [ "$NODE_CHOICE" != private ] && command -v node >/dev/null 2>&1 && [ "$(node_major node)" -ge "$NODE_MAJOR" ]; then
+ NODE="$(command -v node)"
+ NODE_KIND=system
+ elif [ -x "$SHARE/node/bin/node" ] && [ "$(node_major "$SHARE/node/bin/node")" -ge "$NODE_MAJOR" ]; then
+ NODE="$SHARE/node/bin/node"
+ NODE_KIND=private
fi
-else
- die "needs bun (https://bun.sh) or Node >= 22.6 with npm (https://nodejs.org)"
-fi
-
-say ""
-if have "$BIN"; then
- say "hqtui $("$BIN" --version 2>/dev/null || echo '') installed."
-else
- say "hqtui installed to $BIN_DIR, which is not on your PATH. Add it:"
- say " export PATH=\"$BIN_DIR:\$PATH\""
-fi
-say ""
-say "Next:"
-say " hqtui # open the widget showcase"
-say " hqtui doctor # what this terminal supports"
-say " hqtui --help # every command"
-say ""
-say "Building an app? Add the library: bun add @profullstack/hqtui"
+ if [ -z "$NODE" ]; then
+ say "No Node $NODE_MAJOR+ here; fetching an official Node 24 into $SHARE/node ..."
+ TMP="$(mktemp -d)"
+ trap 'rm -rf "$TMP"' EXIT
+ fetch "https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt" "$TMP/SHASUMS256.txt" || fail "cannot reach nodejs.org"
+ TARBALL="$(grep -o "node-v24\.[0-9.]*-$OS-$ARCH\.tar\.gz" "$TMP/SHASUMS256.txt" | head -n 1 || true)"
+ [ -n "$TARBALL" ] || fail "nodejs.org has no Node 24 build for $OS-$ARCH"
+ fetch "https://nodejs.org/dist/latest-v24.x/$TARBALL" "$TMP/$TARBALL" || fail "cannot download $TARBALL"
+ if command -v sha256sum >/dev/null 2>&1; then SUM="$(sha256sum "$TMP/$TARBALL" | cut -d ' ' -f 1)"
+ else SUM="$(shasum -a 256 "$TMP/$TARBALL" | cut -d ' ' -f 1)"; fi
+ grep -q "^$SUM $TARBALL\$" "$TMP/SHASUMS256.txt" || fail "checksum mismatch for $TARBALL"
+ rm -rf "$SHARE/node" && mkdir -p "$SHARE/node"
+ tar -xzf "$TMP/$TARBALL" -C "$SHARE/node" --strip-components=1 || fail "cannot unpack $TARBALL"
+ NODE="$SHARE/node/bin/node"
+ NODE_KIND=private
+ fi
+ if [ "$NODE_KIND" = private ]; then NPM="$SHARE/node/bin/npm"; else NPM="$(dirname "$NODE")/npm"; [ -x "$NPM" ] || NPM=npm; fi
+ # npm runs package scripts with the Node in PATH; make that the chosen one.
+ PATH="$(dirname "$NODE"):$PATH"
+ export PATH
+
+ SPEC="$PKG"
+ [ -n "$VERSION" ] && SPEC="$PKG@$VERSION"
+ say "Installing $SPEC into $SHARE ..."
+ rm -rf "$SHARE/node_modules" "$SHARE/package.json" "$SHARE/package-lock.json"
+ cd "$SHARE"
+ printf '{ "name": "%s-install", "private": true }\n' "$TOOL" > package.json
+ "$NPM" install --silent --no-audit --no-fund --omit=dev "$SPEC" >/dev/null 2>&1 ||
+ "$NPM" install --no-audit --no-fund --omit=dev "$SPEC" || fail "npm could not install $SPEC"
+ PKG_DIR="$SHARE/node_modules/$PKG"
+ [ -f "$PKG_DIR/package.json" ] || fail "$SPEC installed but $PKG_DIR is missing"
+
+ # One shim per entry in the package's own `bin` map: a script, not a symlink, so the
+ # command always runs this copy with the chosen Node.
+ BINS="$("$NODE" -e '
+ const p = require(process.argv[1] + "/package.json");
+ const b = typeof p.bin === "string" ? { [p.name.split("/").pop()]: p.bin } : (p.bin || {});
+ for (const [k, v] of Object.entries(b)) if (/^[A-Za-z0-9._-]+$/.test(k)) console.log(k + "\t" + v);
+ ' "$PKG_DIR")"
+ [ -n "$BINS" ] || fail "$SPEC declares no commands"
+ PATHS="$SHARE"
+ NAMES=""
+ OLDIFS="$IFS"
+ IFS='
+'
+ for line in $BINS; do
+ name="${line%% *}"
+ rel="${line#* }"
+ entry="$PKG_DIR/$rel"
+ [ -f "$entry" ] || fail "$SPEC is missing $rel"
+ printf '#!/bin/sh\n# %s, installed by the %s installer (%s).\nexec "%s" "%s" "$@"\n' "$name" "$TOOL" "$URL" "$NODE" "$entry" > "$BIN/$name"
+ chmod 0755 "$BIN/$name"
+ PATHS="$PATHS
+$BIN/$name"
+ NAMES="$NAMES $name"
+ done
+ IFS="$OLDIFS"
+
+ INSTALLED="$("$NODE" -p "require('$PKG_DIR/package.json').version" 2>/dev/null || echo unknown)"
+ {
+ printf '{\n "package": "%s",\n "version": "%s",\n "installer": "%s",\n' "$PKG" "$INSTALLED" "$URL"
+ printf ' "installedAt": "%s",\n "node": "%s",\n "paths": [\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$NODE_KIND"
+ printf '%s\n' "$PATHS" | sed 's/.*/ "&",/' | sed '$ s/,$//'
+ printf ' ]\n}\n'
+ } > "$SHARE/manifest.json"
+ {
+ echo '#!/bin/sh'
+ echo "# Removes $TOOL. Written by its installer."
+ echo 'set -eu'
+ printf '%s\n' "$PATHS" | sed 's|.*|rm -rf "&"|'
+ echo "echo '$TOOL removed.'"
+ } > "$SHARE/uninstall.sh"
+ chmod 0755 "$SHARE/uninstall.sh"
+
+ say ""
+ say "Installed $PKG $INSTALLED:$NAMES (in $BIN)"
+ case ":$PATH:" in
+ *":$BIN:"*) ;;
+ *) say "Add $BIN to your PATH: echo 'export PATH=\"$BIN:\$PATH\"' >> ~/.profile" ;;
+ esac
+ say "Update by running the installer again; remove with: sh $SHARE/uninstall.sh"
+}
+
+main "$@"
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..65374f0
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,159 @@
+#!/bin/sh
+# hqtui installer.
+#
+# curl -fsSL https://hqtui.com/install.sh | sh
+#
+# Installs @profullstack/hqtui (commands: hqtui) under your home directory. No root, no system
+# package manager, no system files touched. If this machine has no Node 22+,
+# an official Node build is fetched (checksum verified) into the same private directory
+# and used from there. Run this again to update. Remove with:
+# sh ~/.local/share/hqtui/uninstall.sh
+#
+# sh -s -- --version X install a specific release
+# sh -s -- --prefix DIR install root (default: ~/.local)
+#
+# Listed in the W3BS catalog (https://w3bs.org/packages): `w3bs install hqtui` runs
+# this same script and `w3bs update` re-runs it.
+#
+# Generated from the W3BS npm CLI installer template; keep the variables below in sync
+# with package.json. Nothing runs until the last line, so a truncated download is inert.
+
+main() {
+ set -eu
+ PKG="@profullstack/hqtui"
+ TOOL="hqtui"
+ URL="https://hqtui.com/install.sh"
+ NODE_MAJOR="22"
+ PREFIX="${INSTALL_PREFIX:-$HOME/.local}"
+ VERSION=""
+ NODE_CHOICE="${INSTALL_NODE:-auto}"
+
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --version) VERSION="${2:?--version needs a value}"; shift ;;
+ --prefix) PREFIX="${2:?--prefix needs a value}"; shift ;;
+ -h | --help) echo "Usage: curl -fsSL $URL | sh [-s -- --version X --prefix DIR]"; return 0 ;;
+ *) echo "$TOOL installer: unknown option $1" >&2; return 64 ;;
+ esac
+ shift
+ done
+
+ say() { printf '%s\n' "$*"; }
+ fail() { printf '%s installer: %s\n' "$TOOL" "$*" >&2; exit 1; }
+
+ case "$(uname -s)" in
+ Linux) OS=linux ;;
+ Darwin) OS=darwin ;;
+ *) fail "unsupported operating system $(uname -s); Linux and macOS are supported" ;;
+ esac
+ case "$(uname -m)" in
+ x86_64 | amd64) ARCH=x64 ;;
+ aarch64 | arm64) ARCH=arm64 ;;
+ *) fail "unsupported CPU $(uname -m)" ;;
+ esac
+ if command -v curl >/dev/null 2>&1; then
+ fetch() { curl --proto '=https' --tlsv1.2 -fsSL "$1" -o "$2"; }
+ elif command -v wget >/dev/null 2>&1; then
+ fetch() { wget --https-only -q "$1" -O "$2"; }
+ else
+ fail "curl or wget is required"
+ fi
+
+ SHARE="$PREFIX/share/$TOOL"
+ BIN="$PREFIX/bin"
+ mkdir -p "$SHARE" "$BIN"
+
+ node_major() { "$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0; }
+ NODE=""
+ NODE_KIND=""
+ if [ "$NODE_CHOICE" != private ] && command -v node >/dev/null 2>&1 && [ "$(node_major node)" -ge "$NODE_MAJOR" ]; then
+ NODE="$(command -v node)"
+ NODE_KIND=system
+ elif [ -x "$SHARE/node/bin/node" ] && [ "$(node_major "$SHARE/node/bin/node")" -ge "$NODE_MAJOR" ]; then
+ NODE="$SHARE/node/bin/node"
+ NODE_KIND=private
+ fi
+ if [ -z "$NODE" ]; then
+ say "No Node $NODE_MAJOR+ here; fetching an official Node 24 into $SHARE/node ..."
+ TMP="$(mktemp -d)"
+ trap 'rm -rf "$TMP"' EXIT
+ fetch "https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt" "$TMP/SHASUMS256.txt" || fail "cannot reach nodejs.org"
+ TARBALL="$(grep -o "node-v24\.[0-9.]*-$OS-$ARCH\.tar\.gz" "$TMP/SHASUMS256.txt" | head -n 1 || true)"
+ [ -n "$TARBALL" ] || fail "nodejs.org has no Node 24 build for $OS-$ARCH"
+ fetch "https://nodejs.org/dist/latest-v24.x/$TARBALL" "$TMP/$TARBALL" || fail "cannot download $TARBALL"
+ if command -v sha256sum >/dev/null 2>&1; then SUM="$(sha256sum "$TMP/$TARBALL" | cut -d ' ' -f 1)"
+ else SUM="$(shasum -a 256 "$TMP/$TARBALL" | cut -d ' ' -f 1)"; fi
+ grep -q "^$SUM $TARBALL\$" "$TMP/SHASUMS256.txt" || fail "checksum mismatch for $TARBALL"
+ rm -rf "$SHARE/node" && mkdir -p "$SHARE/node"
+ tar -xzf "$TMP/$TARBALL" -C "$SHARE/node" --strip-components=1 || fail "cannot unpack $TARBALL"
+ NODE="$SHARE/node/bin/node"
+ NODE_KIND=private
+ fi
+ if [ "$NODE_KIND" = private ]; then NPM="$SHARE/node/bin/npm"; else NPM="$(dirname "$NODE")/npm"; [ -x "$NPM" ] || NPM=npm; fi
+ # npm runs package scripts with the Node in PATH; make that the chosen one.
+ PATH="$(dirname "$NODE"):$PATH"
+ export PATH
+
+ SPEC="$PKG"
+ [ -n "$VERSION" ] && SPEC="$PKG@$VERSION"
+ say "Installing $SPEC into $SHARE ..."
+ rm -rf "$SHARE/node_modules" "$SHARE/package.json" "$SHARE/package-lock.json"
+ cd "$SHARE"
+ printf '{ "name": "%s-install", "private": true }\n' "$TOOL" > package.json
+ "$NPM" install --silent --no-audit --no-fund --omit=dev "$SPEC" >/dev/null 2>&1 ||
+ "$NPM" install --no-audit --no-fund --omit=dev "$SPEC" || fail "npm could not install $SPEC"
+ PKG_DIR="$SHARE/node_modules/$PKG"
+ [ -f "$PKG_DIR/package.json" ] || fail "$SPEC installed but $PKG_DIR is missing"
+
+ # One shim per entry in the package's own `bin` map: a script, not a symlink, so the
+ # command always runs this copy with the chosen Node.
+ BINS="$("$NODE" -e '
+ const p = require(process.argv[1] + "/package.json");
+ const b = typeof p.bin === "string" ? { [p.name.split("/").pop()]: p.bin } : (p.bin || {});
+ for (const [k, v] of Object.entries(b)) if (/^[A-Za-z0-9._-]+$/.test(k)) console.log(k + "\t" + v);
+ ' "$PKG_DIR")"
+ [ -n "$BINS" ] || fail "$SPEC declares no commands"
+ PATHS="$SHARE"
+ NAMES=""
+ OLDIFS="$IFS"
+ IFS='
+'
+ for line in $BINS; do
+ name="${line%% *}"
+ rel="${line#* }"
+ entry="$PKG_DIR/$rel"
+ [ -f "$entry" ] || fail "$SPEC is missing $rel"
+ printf '#!/bin/sh\n# %s, installed by the %s installer (%s).\nexec "%s" "%s" "$@"\n' "$name" "$TOOL" "$URL" "$NODE" "$entry" > "$BIN/$name"
+ chmod 0755 "$BIN/$name"
+ PATHS="$PATHS
+$BIN/$name"
+ NAMES="$NAMES $name"
+ done
+ IFS="$OLDIFS"
+
+ INSTALLED="$("$NODE" -p "require('$PKG_DIR/package.json').version" 2>/dev/null || echo unknown)"
+ {
+ printf '{\n "package": "%s",\n "version": "%s",\n "installer": "%s",\n' "$PKG" "$INSTALLED" "$URL"
+ printf ' "installedAt": "%s",\n "node": "%s",\n "paths": [\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$NODE_KIND"
+ printf '%s\n' "$PATHS" | sed 's/.*/ "&",/' | sed '$ s/,$//'
+ printf ' ]\n}\n'
+ } > "$SHARE/manifest.json"
+ {
+ echo '#!/bin/sh'
+ echo "# Removes $TOOL. Written by its installer."
+ echo 'set -eu'
+ printf '%s\n' "$PATHS" | sed 's|.*|rm -rf "&"|'
+ echo "echo '$TOOL removed.'"
+ } > "$SHARE/uninstall.sh"
+ chmod 0755 "$SHARE/uninstall.sh"
+
+ say ""
+ say "Installed $PKG $INSTALLED:$NAMES (in $BIN)"
+ case ":$PATH:" in
+ *":$BIN:"*) ;;
+ *) say "Add $BIN to your PATH: echo 'export PATH=\"$BIN:\$PATH\"' >> ~/.profile" ;;
+ esac
+ say "Update by running the installer again; remove with: sh $SHARE/uninstall.sh"
+}
+
+main "$@"