From b64616706fdd220da9a7b6350e7dd9be2956ec00 Mon Sep 17 00:00:00 2001 From: Vladimir Tikhonov Date: Mon, 28 Sep 2026 11:53:34 +0200 Subject: [PATCH] Report a CLI that cannot start instead of asking for an update The nutrient entry point probed `auth --help` with its errors discarded and reported any failure as a missing-account-commands update problem. On Linux hosts without ICU, the .NET runtime aborts on that probe, so users were told to connect to the internet when the fix was installing a system library. Crashes (exit status 126 or higher) now show the binary's own first error lines without managed stack frames, plus the ICU package to install when the error names ICU. A binary that runs but predates account commands keeps the existing message. On Linux, a new install also probes a command that starts the runtime and warns once; the install still completes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 ++ bin/pdf-to-markdown | 66 ++++++++++++++++++++++++++++++++--- bin/pdf-to-text | 35 +++++++++++++++++++ bin/query | 35 +++++++++++++++++++ tests/wrapper-update.test.cjs | 43 ++++++++++++++++++++--- 6 files changed, 172 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c3a26..a01007a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Added the `nutrient` entry point and `nutrient auth login`, `status`, and `logout` workflows. - Added account and API-key integration, offline entitlement handling, and usage reporting to both conversion commands. - Fixed release-download verification on macOS and Linux. Cached commands keep working while the CLI updates. +- When the CLI can't start, the wrapper now shows its actual error instead of asking for an update. On Linux hosts without the ICU libraries, it also names the package to install, at install time and on account commands. ## 0.5.1 — 2026-07-23 diff --git a/README.md b/README.md index f18fec1..34c6a39 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,8 @@ Downloading an update does not by itself establish acceptance of changed license macOS Intel and Rosetta shells are unsupported; npm may allow installation, but the commands refuse to run. +Linux also needs the ICU libraries. Most desktop distributions include them, but slim container images often don't. Install them with `apt-get install libicu-dev` on Debian or Ubuntu, `apk add icu-libs` on Alpine, or `dnf install libicu` on Fedora or RHEL. + Windows binaries are Authenticode-signed and run under Git Bash (`MINGW`/`MSYS`/`Cygwin` environments), bundled with Git for Windows. An x64 Git Bash on Windows-on-ARM detects as x86_64 and fetches the x64 binary, which runs fine under Windows emulation. ## Benchmarks diff --git a/bin/pdf-to-markdown b/bin/pdf-to-markdown index c73021b..d4a397f 100755 --- a/bin/pdf-to-markdown +++ b/bin/pdf-to-markdown @@ -137,6 +137,26 @@ write_state() { STATE_TMP="" } +# Reports a binary that terminated before running a command (exit status 126 or +# higher, which includes death by signal). Shows the binary's own first error +# lines without managed stack frames, plus an install hint for missing ICU. +print_cli_start_failure() { + failure_status="$1" + failure_output="$2" + + echo "The Nutrient CLI could not start (exit status $failure_status):" >&2 + printf '%s\n' "$failure_output" | + sed -e '/^[[:space:]]*at /d' -e '/^[[:space:]]*$/d' \ + -e '/^Aborted\( (core dumped)\)\{0,1\}$/d' -e '/^Killed$/d' \ + -e '/^Segmentation fault\( (core dumped)\)\{0,1\}$/d' | + head -n 3 >&2 + case "$failure_output" in + *ICU*|*libicu*) + echo "Install the ICU libraries, then retry. Debian or Ubuntu: apt-get install libicu-dev. Alpine: apk add icu-libs. Fedora or RHEL: dnf install libicu." >&2 + ;; + esac +} + have_installed_binary() { [ -x "$NUTRIENT_CLI" ] } @@ -376,6 +396,21 @@ install_archive() { esac fi + # --version does not start the .NET runtime. On Linux, probe a command that + # does, so a host missing a runtime dependency (for example the ICU libraries) + # is reported once at install time instead of as an unexplained crash later. + # The install still completes: --version, --help, and --license keep working. + case "$TARGET_ID" in + linux-*) + probe_status=0 + probe_output="$({ "$NEXT_INSTALL_DIR/$(basename "$NUTRIENT_CLI")" auth --help; } 2>&1)" || + probe_status=$? + if [ "$probe_status" -ge 126 ]; then + print_cli_start_failure "$probe_status" "$probe_output" + fi + ;; + esac + # The archive contains only this binary. Replace it with one same-filesystem # rename, leaving the directory and command links available to cached callers. # Readers can execute either complete version; there is no missing-file window. @@ -628,17 +663,38 @@ if ! verb_link_is_valid; then fi fi +# Returns 0 when the installed binary provides account commands, 1 when it runs +# but predates them, and 2 when it cannot start at all. supports_nutrient_auth() { - auth_help="$("$VERB_LINK" auth --help 2>/dev/null)" || return 1 - case "$auth_help" in + AUTH_PROBE_STATUS=0 + AUTH_PROBE_OUTPUT="$({ "$VERB_LINK" auth --help; } 2>&1)" || AUTH_PROBE_STATUS=$? + if [ "$AUTH_PROBE_STATUS" -ge 126 ]; then + case "$TARGET_ID/$AUTH_PROBE_STATUS" in + windows-*/126|windows-*/127) return 1 ;; + *) return 2 ;; + esac + fi + [ "$AUTH_PROBE_STATUS" -eq 0 ] || return 1 + case "$AUTH_PROBE_OUTPUT" in *" auth login"*" auth status"*" auth logout"*) return 0 ;; *) return 1 ;; esac } -if [ "$VERB_NAME" = "nutrient" ] && ! supports_nutrient_auth; then - echo "The installed Nutrient CLI does not support account commands. Connect to the internet and retry so it can be updated." >&2 - exit 1 +if [ "$VERB_NAME" = "nutrient" ]; then + auth_support=0 + supports_nutrient_auth || auth_support=$? + case "$auth_support" in + 0) ;; + 2) + print_cli_start_failure "$AUTH_PROBE_STATUS" "$AUTH_PROBE_OUTPUT" + exit "$AUTH_PROBE_STATUS" + ;; + *) + echo "The installed Nutrient CLI does not support account commands. Connect to the internet and retry so it can be updated." >&2 + exit 1 + ;; + esac fi exec "$VERB_LINK" "$@" diff --git a/bin/pdf-to-text b/bin/pdf-to-text index 7ceb202..6437c14 100755 --- a/bin/pdf-to-text +++ b/bin/pdf-to-text @@ -137,6 +137,26 @@ write_state() { STATE_TMP="" } +# Reports a binary that terminated before running a command (exit status 126 or +# higher, which includes death by signal). Shows the binary's own first error +# lines without managed stack frames, plus an install hint for missing ICU. +print_cli_start_failure() { + failure_status="$1" + failure_output="$2" + + echo "The Nutrient CLI could not start (exit status $failure_status):" >&2 + printf '%s\n' "$failure_output" | + sed -e '/^[[:space:]]*at /d' -e '/^[[:space:]]*$/d' \ + -e '/^Aborted\( (core dumped)\)\{0,1\}$/d' -e '/^Killed$/d' \ + -e '/^Segmentation fault\( (core dumped)\)\{0,1\}$/d' | + head -n 3 >&2 + case "$failure_output" in + *ICU*|*libicu*) + echo "Install the ICU libraries, then retry. Debian or Ubuntu: apt-get install libicu-dev. Alpine: apk add icu-libs. Fedora or RHEL: dnf install libicu." >&2 + ;; + esac +} + have_installed_binary() { [ -x "$NUTRIENT_CLI" ] } @@ -376,6 +396,21 @@ install_archive() { esac fi + # --version does not start the .NET runtime. On Linux, probe a command that + # does, so a host missing a runtime dependency (for example the ICU libraries) + # is reported once at install time instead of as an unexplained crash later. + # The install still completes: --version, --help, and --license keep working. + case "$TARGET_ID" in + linux-*) + probe_status=0 + probe_output="$({ "$NEXT_INSTALL_DIR/$(basename "$NUTRIENT_CLI")" auth --help; } 2>&1)" || + probe_status=$? + if [ "$probe_status" -ge 126 ]; then + print_cli_start_failure "$probe_status" "$probe_output" + fi + ;; + esac + # The archive contains only this binary. Replace it with one same-filesystem # rename, leaving the directory and command links available to cached callers. # Readers can execute either complete version; there is no missing-file window. diff --git a/bin/query b/bin/query index 4cf595e..ad57ca7 100755 --- a/bin/query +++ b/bin/query @@ -128,6 +128,26 @@ write_state() { STATE_TMP="" } +# Reports a binary that terminated before running a command (exit status 126 or +# higher, which includes death by signal). Shows the binary's own first error +# lines without managed stack frames, plus an install hint for missing ICU. +print_cli_start_failure() { + failure_status="$1" + failure_output="$2" + + echo "The Nutrient CLI could not start (exit status $failure_status):" >&2 + printf '%s\n' "$failure_output" | + sed -e '/^[[:space:]]*at /d' -e '/^[[:space:]]*$/d' \ + -e '/^Aborted\( (core dumped)\)\{0,1\}$/d' -e '/^Killed$/d' \ + -e '/^Segmentation fault\( (core dumped)\)\{0,1\}$/d' | + head -n 3 >&2 + case "$failure_output" in + *ICU*|*libicu*) + echo "Install the ICU libraries, then retry. Debian or Ubuntu: apt-get install libicu-dev. Alpine: apk add icu-libs. Fedora or RHEL: dnf install libicu." >&2 + ;; + esac +} + have_installed_binary() { [ -x "$NUTRIENT_CLI" ] } @@ -367,6 +387,21 @@ install_archive() { esac fi + # --version does not start the .NET runtime. On Linux, probe a command that + # does, so a host missing a runtime dependency (for example the ICU libraries) + # is reported once at install time instead of as an unexplained crash later. + # The install still completes: --version, --help, and --license keep working. + case "$TARGET_ID" in + linux-*) + probe_status=0 + probe_output="$({ "$NEXT_INSTALL_DIR/$(basename "$NUTRIENT_CLI")" auth --help; } 2>&1)" || + probe_status=$? + if [ "$probe_status" -ge 126 ]; then + print_cli_start_failure "$probe_status" "$probe_output" + fi + ;; + esac + # The archive contains only this binary. Replace it with one same-filesystem # rename, leaving the directory and command links available to cached callers. # Readers can execute either complete version; there is no missing-file window. diff --git a/tests/wrapper-update.test.cjs b/tests/wrapper-update.test.cjs index 9c72522..622b2d9 100644 --- a/tests/wrapper-update.test.cjs +++ b/tests/wrapper-update.test.cjs @@ -19,10 +19,19 @@ function executable(file, content) { fs.writeFileSync(file, content, { mode: 0o755 }); } -function binary(version, auth = true) { +// Mirrors how a .NET binary aborts on a host without ICU: --version still works, +// but the first command that starts the runtime dies with SIGABRT (status 134). +const icuCrash = `if [ "\${1-} \${2-}" = 'auth --help' ]; then + echo "Process terminated. Couldn't find a valid ICU package installed on the system." >&2 + echo ' at nutrient-linux-amd64!+0x71cb1dc' >&2 + exit 134 +fi +`; + +function binary(version, auth = true, crash = false) { return `#!/bin/sh if [ "\${1-}" = --version ]; then echo 'nutrient ${version}'; exit 0; fi -if [ "\${1-} \${2-}" = 'auth --help' ]; then +${crash ? icuCrash : ''}if [ "\${1-} \${2-}" = 'auth --help' ]; then echo '${auth ? 'nutrient auth login; nutrient auth status; nutrient auth logout' : 'old CLI'}' exit 0 fi @@ -31,7 +40,7 @@ printf '<%s>\\n' "$@" `; } -function fixture(t, { cached = true, windows = false, auth = true } = {}) { +function fixture(t, { cached = true, windows = false, auth = true, crash = false, newCrash = false } = {}) { const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'nutrient-wrapper-test-')); const home = path.join(temp, 'home'); const state = path.join(home, '.local/share/nutrient'); @@ -41,9 +50,9 @@ function fixture(t, { cached = true, windows = false, auth = true } = {}) { const name = windows ? 'nutrient-windows-amd64.exe' : 'nutrient-linux-amd64'; const installed = path.join(cache, name); for (const dir of [cache, fakeBin, payload]) fs.mkdirSync(dir, { recursive: true }); - if (cached) executable(installed, binary('old', auth)); + if (cached) executable(installed, binary('old', auth, crash)); fs.writeFileSync(path.join(state, 'pdf-to-markdown-state'), 'LAST_CHECKED_AT=1\nRELEASE_ID=2026-09-01\n'); - executable(path.join(payload, name), binary('new')); + executable(path.join(payload, name), binary('new', true, newCrash)); const archive = path.join(temp, 'release.tar.gz'); execFileSync('tar', ['-czf', archive, '-C', payload, name]); fs.writeFileSync(`${archive}.sha256`, createHash('sha256').update(fs.readFileSync(archive)).digest('hex')); @@ -204,6 +213,30 @@ test('account commands still reject an incompatible cache while an updater holds assertDispatch(await f.run('pdf-to-markdown', ['standard.pdf']), 'pdf-to-markdown', ['standard.pdf'], 'old'); }); +test('account commands report a CLI that cannot start instead of asking for an update', async t => { + const f = fixture(t, { crash: true }); + f.mark('offline'); + const result = await f.run('nutrient', ['auth', 'status']); + assert.equal(result.code, 134); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /could not start \(exit status 134\)/); + assert.match(result.stderr, /Couldn't find a valid ICU package/); + assert.match(result.stderr, /apt-get install libicu-dev/); + assert.doesNotMatch(result.stderr, //); + assert.doesNotMatch(result.stderr, /does not support account commands/); +}); + +test('a Linux install warns once when the new binary cannot start its runtime', async t => { + const f = fixture(t, { cached: false, newCrash: true }); + const installing = await f.run('pdf-to-markdown', ['first.pdf']); + assertDispatch(installing, 'pdf-to-markdown', ['first.pdf'], 'new'); + assert.match(installing.stderr, /could not start \(exit status 134\)/); + assert.match(installing.stderr, /apt-get install libicu-dev/); + const cached = await f.run('pdf-to-text', ['second.pdf']); + assertDispatch(cached, 'pdf-to-text', ['second.pdf'], 'new'); + assert.doesNotMatch(cached.stderr, /could not start/); +}); + test('Windows command copies update even when a replacement has an older timestamp', async t => { // Exercise Git Bash's branch with POSIX fixture executables, not a Windows runtime test. const f = fixture(t, { windows: true });