Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 61 additions & 5 deletions bin/pdf-to-markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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" "$@"
35 changes: 35 additions & 0 deletions bin/pdf-to-text
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions bin/query
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 38 additions & 5 deletions tests/wrapper-update.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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!<BaseAddress>+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
Expand All @@ -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');
Expand All @@ -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'));
Expand Down Expand Up @@ -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, /<BaseAddress>/);
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 });
Expand Down
Loading