diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a7c505..238ccaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: - run: npm ci - run: npm run check - run: npm run build:sea + - name: Test packaged executable updates + run: EXTRABRAIN_TEST_EXECUTABLE="$PWD/dist/extrabrain" npm test -- test/install.test.ts - name: Smoke test macOS executable shell: bash run: | @@ -37,3 +39,55 @@ jobs: set -e test "$code" = 3 echo "$result" | jq -e '.code == 3' + - name: Install macOS command for the current user + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + arch=$(uname -m) + if [ "$arch" = x86_64 ]; then arch=x64; fi + release="$RUNNER_TEMP/installer-releases/download/v$version" + mkdir -p "$release" + asset="extrabrain-v$version-darwin-$arch.tar.gz" + tar -czf "$release/$asset" -C dist extrabrain + (cd "$release" && shasum -a 256 "$asset" > SHA256SUMS) + install_home="$RUNNER_TEMP/installer-home" + mkdir -p "$install_home" + echo "EXTRABRAIN_TEST_HOME=$install_home" >> "$GITHUB_ENV" + cat install.sh | env HOME="$install_home" ZDOTDIR="$install_home" SHELL=/bin/zsh EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= bash + - name: Verify user installation in new terminals + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + for mode in -lc -ic; do + test "$(env HOME="$EXTRABRAIN_TEST_HOME" ZDOTDIR="$EXTRABRAIN_TEST_HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin /bin/zsh "$mode" 'extrabrain --version')" = "$version" + done + test "$(stat -f '%Su:%Lp' "$EXTRABRAIN_TEST_HOME/.local/bin/extrabrain")" = "$(id -un):755" + - name: Install optional system-wide command + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + cat install.sh | env EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR=/usr/local/bin bash + - name: Verify installed version and access from another account + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + test "$(PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$version" + test "$(stat -f '%Su:%Sg:%Lp' /usr/local/bin/extrabrain)" = root:wheel:755 + test "$(sudo -n -u nobody /usr/bin/env PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$version" + - name: Update system-wide command + shell: bash + run: | + set -euo pipefail + version=$(node -p 'require("./package.json").version') + result=$(EXTRABRAIN_RELEASE_BASE="file://$RUNNER_TEMP/installer-releases" EXTRABRAIN_VERSION="v$version" EXTRABRAIN_INSTALL_DIR= /usr/local/bin/extrabrain --json update) + echo "$result" | jq -e '.code == 0' + - name: Verify version after update + shell: bash + run: | + set -euo pipefail + test "$(PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$(node -p 'require("./package.json").version')" + test "$(stat -f '%Su:%Sg:%Lp' /usr/local/bin/extrabrain)" = root:wheel:755 diff --git a/README.md b/README.md index 4219513..4302564 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,34 @@ Supported release targets are macOS arm64 and x64. The current release is not De macOS: ```sh -curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.sh | sh -export PATH="$HOME/.local/bin:$PATH" +curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.sh | bash +``` + +Open a new terminal window, then check the installed version: + +```sh extrabrain --version ``` -The installer needs no administrator privileges. It uses `curl`, `tar`, and `shasum` or `sha256sum`. +The installer puts the standalone executable in `~/.local/bin` for your account. It needs no administrator password, Node, or Python. For zsh and bash, it adds a small PATH block to your shell startup files automatically, so no manual export command is needed. It uses `curl`, `tar`, and `shasum` or `sha256sum`. + +For zsh, it updates `.zprofile` and `.zshrc` in `ZDOTDIR` or your home directory. For bash, it updates `.bashrc` and the first existing login file among `.bash_profile`, `.bash_login`, and `.profile`, creating `.bash_profile` if none exists. Existing content is preserved, and reinstalling does not duplicate the block. Set `EXTRABRAIN_NO_MODIFY_PATH=1` in the installer's environment to leave shell configuration untouched. Other shells can use `~/.local/bin/extrabrain` directly or a custom destination already on PATH. + +Set `EXTRABRAIN_INSTALL_DIR` in the installer's environment for a custom destination. Custom destinations outside `~/.local/bin` do not change shell profiles. The installer warns if a custom destination is outside PATH or another ExtraBrain executable takes precedence. If automatic shell setup cannot write a profile, it reports the file and the installed executable's full path. + +To install for all local accounts, explicitly select `/usr/local/bin`: + +```sh +curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.sh | env EXTRABRAIN_INSTALL_DIR=/usr/local/bin bash +``` + +This optional mode requests administrator approval when needed and installs a root-owned executable with permissions `755`. Downloads and checksum verification run before elevation; only installation filesystem operations use administrator privileges. Run it in a terminal if authentication is needed. + +If you previously installed in `~/.local/bin`, rerun the default installer to configure your shell automatically. If you have another installation, check `command -v extrabrain` in your new terminal and remove an older copy only if it still takes precedence over the intended installation. -Install a particular stable release with `EXTRABRAIN_VERSION=v0.1.1` in the installer environment. The default is the latest stable GitHub release. Release tags use `vMAJOR.MINOR.PATCH`; CLI and app API versions are independent. An installed executable stays at its installed version during document commands. Run `extrabrain update` or rerun the installer to update it explicitly. `extrabrain --version` reports the installed version. +Install a particular stable release with `EXTRABRAIN_VERSION=v0.1.1` in the installer environment. The default is the latest stable GitHub release. Release tags use `vMAJOR.MINOR.PATCH`; CLI and app API versions are independent. An installed executable stays at its installed version during document commands. Run `extrabrain update` or rerun the installer to update it explicitly. `extrabrain update` replaces the running packaged executable in its current directory unless `EXTRABRAIN_INSTALL_DIR` explicitly overrides the destination. Updating a system-wide installation requires administrator approval when needed, just like installation. Check the version separately with `extrabrain --version` after the update completes. -To uninstall, remove only `~/.local/bin/extrabrain`. Removing the command leaves ExtraBrain application data, imported documents, and import resume manifests intact. +To uninstall the default installation, run `rm ~/.local/bin/extrabrain`. You can leave the PATH block in place for other tools in that directory, or remove the block marked `# ExtraBrain CLI: user-local PATH` from the configured shell files. For a system-wide installation, run `sudo rm /usr/local/bin/extrabrain`; for a custom installation, remove only `extrabrain` from that directory. Removing the command leaves ExtraBrain application data, imported documents, and import resume manifests intact. ## App compatibility diff --git a/install.sh b/install.sh index 4cbbbb5..5f1d1d1 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,25 @@ #!/bin/sh set -eu +umask 022 repository='ExtraBrainApp/ExtraBrain-cli' release_base=${EXTRABRAIN_RELEASE_BASE:-"https://github.com/$repository/releases"} install_dir=${EXTRABRAIN_INSTALL_DIR:-"$HOME/.local/bin"} +use_sudo=0 +system_install=0 +user_install=0 +case "$install_dir" in + /usr/local/bin|/usr/local/bin/) system_install=1 ;; + "$HOME/.local/bin"|"$HOME/.local/bin/") user_install=1 ;; +esac + +install_command() { + if [ "$use_sudo" -eq 1 ]; then + sudo -n "$@" + else + "$@" + fi +} case "$(uname -s)" in Darwin) platform=darwin ;; @@ -30,7 +46,7 @@ asset="extrabrain-$version-$platform-$arch.tar.gz" temporary=$(mktemp -d) staging='' cleanup() { - [ -z "$staging" ] || rm -f "$staging" + [ -z "$staging" ] || install_command /bin/rm -f "$staging" || : rm -rf "$temporary" } trap cleanup EXIT HUP INT TERM @@ -56,9 +72,106 @@ if [ "$actual" != "$expected" ]; then fi tar -xzf "$temporary/$asset" -C "$temporary" extrabrain -mkdir -p "$install_dir" -staging=$(mktemp "$install_dir/.extrabrain.XXXXXXXX") -install -m 755 "$temporary/extrabrain" "$staging" -mv -f "$staging" "$install_dir/extrabrain" +if [ ! -f "$temporary/extrabrain" ] || [ -L "$temporary/extrabrain" ]; then + echo 'Release executable is missing or invalid' >&2 + exit 1 +fi +if [ -L "$install_dir/extrabrain" ] || { [ -e "$install_dir/extrabrain" ] && [ ! -f "$install_dir/extrabrain" ]; }; then + echo "Refusing to replace a symlink or non-regular file at $install_dir/extrabrain" >&2 + exit 1 +fi + +# System installs are root-owned. Custom writable destinations remain user-owned. +parent=$install_dir +while [ ! -d "$parent" ]; do + parent=$(dirname "$parent") +done +if [ "$user_install" -eq 1 ] && [ ! -w "$parent" ]; then + echo "User installation directory is not writable: $install_dir. Fix its ownership or choose a writable EXTRABRAIN_INSTALL_DIR." >&2 + exit 1 +fi +if [ "$(id -u)" -ne 0 ] && { [ "$system_install" -eq 1 ] || [ ! -w "$parent" ]; }; then + use_sudo=1 + echo "Administrator access is required to install at $install_dir" >&2 + if ! command -v sudo >/dev/null 2>&1; then + echo 'Ask an administrator to run the installer, or choose a writable EXTRABRAIN_INSTALL_DIR already on PATH.' >&2 + exit 1 + fi + if ! sudo -n -v 2>/dev/null; then + if ! ( : /dev/null; then + echo 'No terminal is available for administrator authentication. Rerun the installer in a terminal, or ask an administrator to install it.' >&2 + exit 1 + fi + if ! sudo -v &2 + exit 1 + fi + fi +fi + +install_command /bin/mkdir -p -m 755 "$install_dir" +staging=$(install_command /usr/bin/mktemp "$install_dir/.extrabrain.XXXXXXXX") +if [ "$system_install" -eq 1 ] || [ "$use_sudo" -eq 1 ]; then + install_command /usr/bin/install -o root -g wheel -m 755 "$temporary/extrabrain" "$staging" +else + install_command /usr/bin/install -m 755 "$temporary/extrabrain" "$staging" +fi +install_command /bin/mv -f "$staging" "$install_dir/extrabrain" staging='' echo "Installed extrabrain $version at $install_dir/extrabrain" + +path_configured=0 +if [ "$user_install" -eq 1 ] && [ "${EXTRABRAIN_NO_MODIFY_PATH:-0}" != 1 ]; then + cat > "$temporary/path-setup" <<'EOF' +# ExtraBrain CLI: user-local PATH +case ":$PATH:" in + *":$HOME/.local/bin:"*) ;; + *) export PATH="$HOME/.local/bin:$PATH" ;; +esac +EOF + configure_profile() { + profile_file=$1 + if [ -f "$profile_file" ] && grep -Fqx '# ExtraBrain CLI: user-local PATH' "$profile_file"; then + return 0 + fi + if mkdir -p "$(dirname "$profile_file")" && { printf '\n'; cat "$temporary/path-setup"; } >> "$profile_file"; then + echo "Configured PATH in $profile_file" + return 0 + fi + echo "Warning: could not configure PATH in $profile_file. The executable is installed at $install_dir/extrabrain." >&2 + return 1 + } + path_configured=1 + case "${SHELL:-/bin/zsh}" in + */zsh) + configure_profile "${ZDOTDIR:-$HOME}/.zprofile" || path_configured=0 + configure_profile "${ZDOTDIR:-$HOME}/.zshrc" || path_configured=0 + ;; + */bash) + login_profile="$HOME/.bash_profile" + if [ -e "$HOME/.bash_profile" ] || [ -L "$HOME/.bash_profile" ]; then + login_profile="$HOME/.bash_profile" + elif [ -e "$HOME/.bash_login" ] || [ -L "$HOME/.bash_login" ]; then + login_profile="$HOME/.bash_login" + elif [ -e "$HOME/.profile" ] || [ -L "$HOME/.profile" ]; then + login_profile="$HOME/.profile" + fi + configure_profile "$login_profile" || path_configured=0 + configure_profile "$HOME/.bashrc" || path_configured=0 + ;; + *) + path_configured=0 + echo "Warning: automatic PATH setup supports zsh and bash. Use $install_dir/extrabrain directly with your shell." >&2 + ;; + esac +fi +if [ "$path_configured" -eq 1 ]; then + echo 'Open a new terminal, then run: extrabrain --version' +fi + +resolved=$(command -v extrabrain || :) +if [ -z "$resolved" ] && [ "$path_configured" -eq 0 ]; then + echo "Warning: $install_dir is not on this terminal's PATH. Use $install_dir/extrabrain directly, or install into a directory already on PATH." >&2 +elif [ -n "$resolved" ] && [ ! "$resolved" -ef "$install_dir/extrabrain" ]; then + echo "Warning: PATH currently resolves extrabrain to $resolved. Check command -v extrabrain in a new terminal; remove or update an older copy if it still takes precedence." >&2 +fi diff --git a/src/update.ts b/src/update.ts index 6c015ad..295b6f6 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,4 +1,6 @@ import { spawn, spawnSync } from 'node:child_process' +import { dirname } from 'node:path' +import { isSea } from 'node:sea' import installShell from '../install.sh?raw' import installPowerShell from '../install.ps1?raw' import { CliApiError } from './apiClient.service' @@ -21,10 +23,18 @@ export const updateCli = (): { scheduled: boolean } => { const result = spawnSync('sh', ['-s'], { encoding: 'utf8', input: installShell, + env: { + ...process.env, + ...(isSea() && !process.env.EXTRABRAIN_INSTALL_DIR + ? { EXTRABRAIN_INSTALL_DIR: dirname(process.execPath) } + : {}) + }, timeout: 120_000 }) if (result.error || result.status !== 0) { - throw new CliApiError('UPDATE_FAILED', 'Verified CLI update failed') + const detail = result.stderr?.trim() || result.error?.message || `Installer exited with status ${result.status}` + throw new CliApiError('UPDATE_FAILED', `Verified CLI update failed: ${detail}`) } + if (result.stderr) process.stderr.write(result.stderr) return { scheduled: false } } diff --git a/test/install.test.ts b/test/install.test.ts index 7be2d65..65d80d3 100644 --- a/test/install.test.ts +++ b/test/install.test.ts @@ -1,8 +1,9 @@ -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { DocumentApiClient } from '../src/apiClient.service' import { ResumeStore } from '../src/resumeStore.service' @@ -14,14 +15,15 @@ const platform = 'darwin' const arch = process.arch === 'arm64' ? 'arm64' : 'x64' const asset = `extrabrain-${version}-${platform}-${arch}.tar.gz` -const fixture = async () => { - const directory = await mkdtemp(join(tmpdir(), 'extrabrain-install-')) +const fixture = async (prefix = 'extrabrain-install-') => { + const directory = await mkdtemp(join(tmpdir(), prefix)) directories.push(directory) const release = join(directory, 'releases', 'download', version) const installDir = join(directory, 'bin') + const releaseBase = pathToFileURL(join(directory, 'releases')).href await mkdir(release, { recursive: true }) const source = join(directory, 'extrabrain') - const publish = async (contents: string, valid = true) => { + const publish = async (contents: string | Uint8Array, valid = true) => { await writeFile(source, contents) const tar = spawnSync('tar', ['-czf', join(release, asset), '-C', directory, 'extrabrain']) expect(tar.status).toBe(0) @@ -34,13 +36,30 @@ const fixture = async () => { encoding: 'utf8', env: { ...process.env, - EXTRABRAIN_RELEASE_BASE: `file://${join(directory, 'releases')}`, + PATH: `${installDir}:${process.env.PATH ?? ''}`, + EXTRABRAIN_RELEASE_BASE: releaseBase, EXTRABRAIN_VERSION: version, EXTRABRAIN_INSTALL_DIR: installDir, ...extraEnv } }) - return { directory, installDir, install, publish } + const userEnv = { + ...process.env, + HOME: directory, + ZDOTDIR: directory, + BASH_ENV: '', + SHELL: '/bin/zsh', + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', + EXTRABRAIN_RELEASE_BASE: releaseBase, + EXTRABRAIN_VERSION: version, + EXTRABRAIN_INSTALL_DIR: '', + EXTRABRAIN_NO_MODIFY_PATH: '0' + } + const installForUser = (extraEnv: Record = {}) => spawnSync('bash', ['-c', 'cat install.sh | bash'], { + encoding: 'utf8', + env: { ...userEnv, ...extraEnv } + }) + return { directory, installDir, install, publish, releaseBase, userEnv, installForUser } } afterEach(async () => { @@ -56,7 +75,7 @@ describe.skipIf(process.platform !== 'darwin')('public shell installer', () => { await state.publish('second') expect(state.install().status).toBe(0) expect(await readFile(join(state.installDir, 'extrabrain'), 'utf8')).toBe('second') - }) + }, 30_000) it('keeps the installed executable when a release is corrupt', async () => { const state = await fixture() @@ -69,6 +88,168 @@ describe.skipIf(process.platform !== 'darwin')('public shell installer', () => { expect(await readFile(join(state.installDir, 'extrabrain'), 'utf8')).toBe('original') }) + it('is immediately available by name after piped installation into a directory on PATH', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const result = spawnSync('sh', ['-c', 'cat install.sh | sh && extrabrain --version'], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${state.installDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + EXTRABRAIN_RELEASE_BASE: state.releaseBase, + EXTRABRAIN_VERSION: version, + EXTRABRAIN_INSTALL_DIR: state.installDir + } + }) + expect(result.status).toBe(0) + expect(result.stdout.trim().split('\n').at(-1)).toBe('0.1.0') + expect(result.stderr).toBe('') + expect((await stat(join(state.installDir, 'extrabrain'))).mode & 0o777).toBe(0o755) + }) + + it.skipIf(process.getuid?.() === 0)('elevates only installation operations for an explicit system destination', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const fakeBin = join(state.directory, 'fake-bin') + await mkdir(fakeBin) + const log = join(state.directory, 'privileged-commands') + const sudo = join(fakeBin, 'sudo') + await writeFile(sudo, `#!/bin/sh +[ "$1" = -n ] || exit 1 +shift +[ "$1" != -v ] || exit 0 +printf '%s\\n' "$*" >> "$EXTRABRAIN_TEST_PRIVILEGE_LOG" +if [ "$1" = /usr/bin/install ]; then + shift + [ "$1" = -o ] && [ "$2" = root ] && [ "$3" = -g ] && [ "$4" = wheel ] || exit 1 + shift 4 + exec /usr/bin/install "$@" +fi +exec "$@" +`) + await chmod(sudo, 0o755) + // Relocate only the system directory constant so this test never changes the host installation. + const installer = join(state.directory, 'install.sh') + await writeFile(installer, (await readFile('install.sh', 'utf8')).replaceAll('/usr/local/bin', state.installDir)) + const result = spawnSync('sh', ['-c', 'cat "$1" | sh && extrabrain --version', 'sh', installer], { + encoding: 'utf8', + env: { + ...process.env, + HOME: state.directory, + PATH: `${state.installDir}:${fakeBin}:/usr/bin:/bin`, + EXTRABRAIN_RELEASE_BASE: state.releaseBase, + EXTRABRAIN_VERSION: version, + EXTRABRAIN_INSTALL_DIR: state.installDir, + EXTRABRAIN_TEST_PRIVILEGE_LOG: log + } + }) + expect(result.status).toBe(0) + expect(result.stdout.trim().split('\n').at(-1)).toBe('0.1.0') + const commands = (await readFile(log, 'utf8')).trim().split('\n') + expect(commands.map((line) => line.split(' ')[0])).toEqual(['/bin/mkdir', '/usr/bin/mktemp', '/usr/bin/install', '/bin/mv']) + expect(commands[2]).toContain('-o root -g wheel -m 755') + expect(result.stderr).toContain(`Administrator access is required to install at ${state.installDir}`) + await expect(stat(join(state.directory, '.local/bin/extrabrain'))).rejects.toThrow() + }) + + it('explains when a custom destination is outside PATH', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const result = state.install({ PATH: '/usr/bin:/bin:/usr/sbin:/sbin' }) + expect(result.status).toBe(0) + expect(result.stderr).toContain('not on this terminal\'s PATH') + expect(result.stderr).toContain(state.installDir) + }) + + it('identifies an older executable that shadows the installation', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const oldBin = join(state.directory, 'old-bin') + await mkdir(oldBin) + const oldExecutable = join(oldBin, 'extrabrain') + await writeFile(oldExecutable, '#!/bin/sh\necho old\n') + await chmod(oldExecutable, 0o755) + const result = state.install({ PATH: `${oldBin}:${state.installDir}:/usr/bin:/bin` }) + expect(result.status).toBe(0) + expect(result.stderr).toContain(`PATH currently resolves extrabrain to ${oldExecutable}`) + expect(await readFile(oldExecutable, 'utf8')).toContain('echo old') + }) + + it.skipIf(process.getuid?.() === 0)('requires administrator access for an explicit system destination without a user-local fallback', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const fakeBin = join(state.directory, 'fake-bin') + await mkdir(fakeBin) + const sudo = join(fakeBin, 'sudo') + await writeFile(sudo, '#!/bin/sh\necho "Authorization denied" >&2\nexit 1\n') + await chmod(sudo, 0o755) + const result = await new Promise<{ status: number | null; stderr: string }>((resolve, reject) => { + const child = spawn('sh', ['-c', 'cat install.sh | sh'], { + detached: true, + env: { + ...process.env, + HOME: state.directory, + PATH: `${fakeBin}:/usr/bin:/bin:/usr/sbin:/sbin`, + EXTRABRAIN_RELEASE_BASE: state.releaseBase, + EXTRABRAIN_VERSION: version, + EXTRABRAIN_INSTALL_DIR: '/usr/local/bin' + }, + stdio: ['ignore', 'ignore', 'pipe'] + }) + let stderr = '' + child.stderr!.on('data', (chunk) => { stderr += chunk.toString() }) + child.on('error', reject) + child.on('close', (status) => resolve({ status, stderr })) + }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Administrator access is required to install at /usr/local/bin') + expect(result.stderr).toContain('No terminal is available for administrator authentication') + await expect(stat(join(state.directory, '.local/bin/extrabrain'))).rejects.toThrow() + }) + + it.skipIf(process.getuid?.() === 0)('preserves an installed executable when authorization for an unwritable destination fails', async () => { + const state = await fixture() + await state.publish('original') + expect(state.install().status).toBe(0) + await state.publish('replacement') + const fakeBin = join(state.directory, 'fake-bin') + await mkdir(fakeBin) + await writeFile(join(fakeBin, 'sudo'), '#!/bin/sh\nexit 1\n') + await chmod(join(fakeBin, 'sudo'), 0o755) + await chmod(state.installDir, 0o555) + try { + const result = state.install({ PATH: `${fakeBin}:/usr/bin:/bin` }) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Administrator access is required') + expect(await readFile(join(state.installDir, 'extrabrain'), 'utf8')).toBe('original') + } finally { + await chmod(state.installDir, 0o755) + } + }) + + it('supports a custom installation directory containing spaces', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const installDir = join(state.directory, 'custom bin') + const result = state.install({ EXTRABRAIN_INSTALL_DIR: installDir, PATH: `${installDir}:/usr/bin:/bin` }) + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + expect(spawnSync(join(installDir, 'extrabrain'), ['--version'], { encoding: 'utf8' }).stdout.trim()).toBe('0.1.0') + }) + + it('refuses a symlink destination without changing the linked executable', async () => { + const state = await fixture() + await state.publish('replacement') + await mkdir(state.installDir) + const oldExecutable = join(state.directory, 'old-extrabrain') + await writeFile(oldExecutable, 'original') + await symlink(oldExecutable, join(state.installDir, 'extrabrain')) + const result = state.install() + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Refusing to replace a symlink') + expect(await readFile(oldExecutable, 'utf8')).toBe('original') + }) + it('rejects unsupported platforms before downloading', async () => { const state = await fixture() const fakeBin = join(state.directory, 'fake-bin') @@ -81,6 +262,123 @@ describe.skipIf(process.platform !== 'darwin')('public shell installer', () => { expect(result.stderr).toContain('Unsupported operating system') }) + it.skipIf(process.getuid?.() === 0)('installs for the current user without sudo and makes the command available in a new zsh terminal', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const fakeBin = join(state.directory, 'fake-bin') + await mkdir(fakeBin) + await writeFile(join(fakeBin, 'sudo'), '#!/bin/sh\necho "Unexpected administrator request" >&2\nexit 1\n') + await chmod(join(fakeBin, 'sudo'), 0o755) + const installed = state.installForUser({ PATH: `${fakeBin}:${state.userEnv.PATH}` }) + expect(installed.status).toBe(0) + expect(installed.stderr).toBe('') + expect(installed.stdout).toContain('Open a new terminal') + const target = join(state.directory, '.local/bin/extrabrain') + expect((await stat(target)).uid).toBe(process.getuid!()) + expect((await stat(target)).mode & 0o777).toBe(0o755) + for (const mode of ['-lc', '-ic']) { + const versionCheck = spawnSync('/bin/zsh', [mode, 'extrabrain --version'], { encoding: 'utf8', env: state.userEnv }) + expect(versionCheck.status).toBe(0) + expect(versionCheck.stdout.trim()).toBe('0.1.0') + } + }) + + it('preserves zsh configuration and avoids duplicate PATH blocks on reinstall', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const existing = 'export EXTRABRAIN_PROFILE_TEST=preserved\n' + await writeFile(join(state.directory, '.zshrc'), existing) + expect(state.installForUser().status).toBe(0) + const first = await readFile(join(state.directory, '.zshrc'), 'utf8') + expect(first.startsWith(existing)).toBe(true) + expect(state.installForUser().status).toBe(0) + expect(await readFile(join(state.directory, '.zshrc'), 'utf8')).toBe(first) + const result = spawnSync('/bin/zsh', ['-lic', 'printf "%s\\n%s" "$EXTRABRAIN_PROFILE_TEST" "$PATH"'], { + encoding: 'utf8', env: state.userEnv + }) + expect(result.status).toBe(0) + const [preserved, path] = result.stdout.split('\n') + expect(preserved).toBe('preserved') + expect(path.split(':').filter((entry) => entry === join(state.directory, '.local/bin'))).toHaveLength(1) + }) + + for (const loginProfile of ['.bash_profile', '.bash_login', '.profile']) { + it(`configures bash while preserving the existing ${loginProfile} login file`, async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const existing = 'export EXTRABRAIN_PROFILE_TEST=preserved\n' + await writeFile(join(state.directory, loginProfile), existing) + expect(state.installForUser({ SHELL: '/bin/bash' }).status).toBe(0) + expect((await readFile(join(state.directory, loginProfile), 'utf8')).startsWith(existing)).toBe(true) + if (loginProfile !== '.bash_profile') await expect(stat(join(state.directory, '.bash_profile'))).rejects.toThrow() + for (const mode of ['-lc', '-ic']) { + const result = spawnSync('/bin/bash', [mode, 'extrabrain --version'], { encoding: 'utf8', env: state.userEnv }) + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe('0.1.0') + } + }) + } + + it('respects a custom ZDOTDIR and supports home directories with spaces', async () => { + const state = await fixture('extrabrain-install with spaces-') + await state.publish('#!/bin/sh\necho 0.1.0\n') + const shellDirectory = join(state.directory, 'dotfiles') + const installed = state.installForUser({ ZDOTDIR: shellDirectory }) + expect(installed.status).toBe(0) + expect(installed.stderr).toBe('') + await expect(stat(join(state.directory, '.zshrc'))).rejects.toThrow() + const result = spawnSync('/bin/zsh', ['-lc', 'extrabrain --version'], { + encoding: 'utf8', env: { ...state.userEnv, ZDOTDIR: shellDirectory } + }) + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe('0.1.0') + }) + + it('allows opting out of shell modifications and reports unsupported shells', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const optedOut = state.installForUser({ EXTRABRAIN_NO_MODIFY_PATH: '1' }) + expect(optedOut.status).toBe(0) + expect(optedOut.stderr).toContain('not on this terminal\'s PATH') + await expect(stat(join(state.directory, '.zshrc'))).rejects.toThrow() + const unsupported = state.installForUser({ SHELL: '/usr/local/bin/fish' }) + expect(unsupported.status).toBe(0) + expect(unsupported.stderr).toContain('automatic PATH setup supports zsh and bash') + await expect(stat(join(state.directory, '.zshrc'))).rejects.toThrow() + }) + + it.skipIf(process.getuid?.() === 0)('reports unwritable profiles without altering their contents or requesting sudo', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho 0.1.0\n') + const profile = join(state.directory, '.zshrc') + await writeFile(profile, '# Existing configuration\n') + await chmod(profile, 0o444) + const result = state.installForUser() + expect(result.status).toBe(0) + expect(result.stderr).toContain(`could not configure PATH in ${profile}`) + expect(result.stdout).not.toContain('Open a new terminal') + expect(await readFile(profile, 'utf8')).toBe('# Existing configuration\n') + expect(result.stderr).not.toContain('Administrator access') + }) + + it.skipIf(process.getuid?.() === 0)('does not elevate when the default user directory is unwritable', async () => { + const state = await fixture() + await state.publish('#!/bin/sh\necho original\n') + expect(state.installForUser().status).toBe(0) + const installDir = join(state.directory, '.local/bin') + await chmod(installDir, 0o555) + await state.publish('replacement') + try { + const result = state.installForUser() + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('User installation directory is not writable') + expect(result.stderr).not.toContain('Administrator access') + expect(await readFile(join(installDir, 'extrabrain'), 'utf8')).toContain('echo original') + } finally { + await chmod(installDir, 0o755) + } + }) + it('updates through the verified installer only on explicit request', async () => { const state = await fixture() await state.publish('#!/bin/sh\necho 0.1.0\n') @@ -91,12 +389,14 @@ describe.skipIf(process.platform !== 'darwin')('public shell installer', () => { const original = { base: process.env.EXTRABRAIN_RELEASE_BASE, version: process.env.EXTRABRAIN_VERSION, - directory: process.env.EXTRABRAIN_INSTALL_DIR + directory: process.env.EXTRABRAIN_INSTALL_DIR, + path: process.env.PATH } try { - process.env.EXTRABRAIN_RELEASE_BASE = `file://${join(state.directory, 'releases')}` + process.env.EXTRABRAIN_RELEASE_BASE = state.releaseBase process.env.EXTRABRAIN_VERSION = version process.env.EXTRABRAIN_INSTALL_DIR = state.installDir + process.env.PATH = `${state.installDir}:${original.path ?? ''}` const output: string[] = [] const dependencies = { apiFactory: () => ({ @@ -121,7 +421,85 @@ describe.skipIf(process.platform !== 'darwin')('public shell installer', () => { else process.env.EXTRABRAIN_VERSION = original.version if (original.directory === undefined) delete process.env.EXTRABRAIN_INSTALL_DIR else process.env.EXTRABRAIN_INSTALL_DIR = original.directory + if (original.path === undefined) delete process.env.PATH + else process.env.PATH = original.path } expect(spawnSync(target, ['--version'], { encoding: 'utf8' }).stdout.trim()).toBe('0.1.1') }) + + describe.skipIf(!process.env.EXTRABRAIN_TEST_EXECUTABLE)('packaged executable updates', () => { + for (const location of ['custom/bin', '.local/bin']) { + it(`updates the running executable in ${location} without a destination override`, async () => { + const state = await fixture() + const installDir = join(state.directory, location) + await state.publish(await readFile(process.env.EXTRABRAIN_TEST_EXECUTABLE!)) + expect(state.install({ EXTRABRAIN_INSTALL_DIR: installDir }).status).toBe(0) + await state.publish('#!/bin/sh\necho updated\n') + const target = join(installDir, 'extrabrain') + const result = spawnSync(target, ['--json', 'update'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: state.directory, + ZDOTDIR: state.directory, + PATH: `${installDir}:/usr/bin:/bin`, + EXTRABRAIN_RELEASE_BASE: state.releaseBase, + EXTRABRAIN_VERSION: version, + EXTRABRAIN_INSTALL_DIR: '' + } + }) + expect(result.status).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ code: 0, message: 'CLI updated.' }) + expect(result.stderr).toBe('') + expect(spawnSync(target, ['--version'], { encoding: 'utf8' }).stdout.trim()).toBe('updated') + }, 30_000) + } + + it('honors an explicit destination override', async () => { + const state = await fixture() + const original = await readFile(process.env.EXTRABRAIN_TEST_EXECUTABLE!) + await state.publish(original) + expect(state.install().status).toBe(0) + await state.publish('#!/bin/sh\necho updated\n') + const override = join(state.directory, 'other-bin') + const target = join(state.installDir, 'extrabrain') + const result = spawnSync(target, ['--json', 'update'], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${override}:/usr/bin:/bin`, + EXTRABRAIN_RELEASE_BASE: state.releaseBase, + EXTRABRAIN_VERSION: version, + EXTRABRAIN_INSTALL_DIR: override + } + }) + expect(result.status).toBe(0) + expect(JSON.parse(result.stdout).code).toBe(0) + expect(createHash('sha256').update(await readFile(target)).digest('hex')) + .toBe(createHash('sha256').update(original).digest('hex')) + expect(spawnSync(join(override, 'extrabrain'), ['--version'], { encoding: 'utf8' }).stdout.trim()).toBe('updated') + }, 30_000) + + it('returns installer failure details as valid JSON without replacing the executable', async () => { + const state = await fixture() + const original = await readFile(process.env.EXTRABRAIN_TEST_EXECUTABLE!) + await state.publish(original) + expect(state.install().status).toBe(0) + await state.publish('corrupt', false) + const target = join(state.installDir, 'extrabrain') + const result = spawnSync(target, ['--json', 'update'], { + encoding: 'utf8', + env: { + ...process.env, + EXTRABRAIN_RELEASE_BASE: state.releaseBase, + EXTRABRAIN_VERSION: version, + EXTRABRAIN_INSTALL_DIR: '' + } + }) + expect(result.status).toBe(1) + expect(JSON.parse(result.stdout)).toMatchObject({ code: 1, message: expect.stringContaining('Release checksum mismatch') }) + expect(createHash('sha256').update(await readFile(target)).digest('hex')) + .toBe(createHash('sha256').update(original).digest('hex')) + }, 30_000) + }) }) diff --git a/test/sessionExport.service.test.ts b/test/sessionExport.service.test.ts index 8de2b20..31fa938 100644 --- a/test/sessionExport.service.test.ts +++ b/test/sessionExport.service.test.ts @@ -212,7 +212,7 @@ describe('session exports', () => { expect((await readFile(join(output, partPath))).length).toBeGreaterThan(16 * 1024 * 1024) expect((manifest.parts as Array<{ fetched: number }>)[0].fetched).toBe(recordCount) expect(manifest.retrieval).toEqual({ complete: true }) - }) + }, 30_000) it('exports every collection and multiple analyses under one snapshot', async () => { const { service, directory } = await fixture()