Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
26755d8
test(npm-wrapper): add failing scaffold for ENOENT detection (F003 S1)
0xmariowu Apr 26, 2026
b95afe5
fix(npm-wrapper): exit non-zero on spawn ENOENT with fix hint (F003 S2)
0xmariowu Apr 26, 2026
92a71f3
test(install-sh): add failing scaffold for PATH persistence (F003 S3)
0xmariowu Apr 26, 2026
6394c7f
fix(install-sh): persist PATH to shell profile only when missing (F00…
0xmariowu Apr 26, 2026
add1b70
fix(npm-wrapper): prepend ~/.local/bin to PATH before post-install sp…
0xmariowu Apr 26, 2026
669f486
fix(npm-wrapper): pass --no-init to installer to avoid implicit init …
0xmariowu Apr 26, 2026
49caa13
test(install-script): add e2e smoke for install → spawn happy path (F…
0xmariowu Apr 26, 2026
91de5db
chore(gitleaks): allowlist install + npm-wrapper paths for .local XDG…
0xmariowu Apr 26, 2026
034b1f2
fix(install-sh): prefer sourced bash profile
0xmariowu Apr 26, 2026
2aabaa4
docs(install-sh): clarify path persistence writes
0xmariowu Apr 26, 2026
3ffccdf
test(npm-wrapper): skip POSIX wrapper shims on Windows
0xmariowu Apr 26, 2026
ac11761
test(install-script): skip e2e bash stub on Windows
0xmariowu Apr 26, 2026
72f513d
fix(npm-wrapper): handle spawn permission errors
0xmariowu Apr 26, 2026
3253b12
fix(p0-6): redact scenario error + stderr in e2b reports (#432)
0xmariowu Apr 26, 2026
5fbfd89
chore(gitleaks): widen install/wrapper allowlist to cover new permiss…
0xmariowu Apr 26, 2026
a5dc27d
fix(install-sh): skip PATH persist for unknown shells (don't write ba…
0xmariowu Apr 26, 2026
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
52 changes: 52 additions & 0 deletions npm/bin/autosearch-ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,49 @@ function hasAutosearch() {
return getInstalledAutosearchVersion() !== null;
}

function expectedAutosearchPath() {
if (isWindows()) {
return "%USERPROFILE%\\AppData\\Roaming\\Python\\Python312\\Scripts\\autosearch.exe";
}
return `${process.env.HOME || "~"}/.local/bin/autosearch`;
Comment thread
0xmariowu marked this conversation as resolved.
}

function prependHomeLocalBinToPath() {
if (!process.env.HOME) return;

const localBin = `${process.env.HOME}/.local/bin`;
Comment thread
0xmariowu marked this conversation as resolved.
const separator = isWindows() ? ";" : ":";
const currentPath = process.env.PATH || "";
if (currentPath.split(separator).includes(localBin)) return;

process.env.PATH = currentPath
? `${localBin}${separator}${currentPath}`
: localBin;
}

function printAutosearchNotFoundHint(error) {
process.stderr.write(
`\nautosearch not found after install.\n` +
`Expected executable: ${expectedAutosearchPath()}\n` +
`Your PATH may not include the install location yet.\n` +
`Re-source your shell profile or install AutoSearch directly:\n` +
` curl -fsSL ${INSTALL_SCRIPT} | bash\n` +
` pipx install autosearch && autosearch init\n` +
` pip install --user autosearch && autosearch init\n` +
(error?.message ? `\nOriginal error: ${error.message}\n` : "\n"),
);
}

function printInstallerNotFoundHint(error) {
process.stderr.write(
`\nUnable to start the AutoSearch installer.\n` +
`The underlying installer command was not found. Ensure one of these ` +
`commands is available on PATH: curl, bash, pipx, py, python.\n` +
`Then re-run: npx autosearch-ai --yes\n` +
(error?.message ? `\nOriginal error: ${error.message}\n` : "\n"),
);
}

// Bug 6 (fix-plan v8 follow-up): compare the wrapper's expected Python CLI
// version against what's actually installed. The npm package version
// `YYYY.M.DD` derives from pyproject `YYYY.MM.DD.N` (N is the daily counter
Expand Down Expand Up @@ -226,15 +269,24 @@ async function main() {
}
}
const result = runInstall();
if (result.error?.code === "ENOENT") {
printInstallerNotFoundHint(result.error);
process.exit(1);
}
if ((result.status ?? 0) !== 0) {
process.exit(result.status ?? 1);
}
prependHomeLocalBinToPath();
} else {
checkVersionAlignment(installedVersion);
}

const cmd = args.length > 0 ? args : ["init"];
const result = spawnSync("autosearch", cmd, { stdio: "inherit" });
if (result.error?.code === "ENOENT") {
printAutosearchNotFoundHint(result.error);
process.exit(1);
}
process.exit(result.status ?? 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down
56 changes: 42 additions & 14 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ GREEN="\033[32m"
YELLOW="\033[33m"
RED="\033[31m"
RESET="\033[0m"
ORIGINAL_PATH="$PATH"

info() { echo -e "${BOLD}$*${RESET}"; }
success() { echo -e "${GREEN}✓${RESET} $*"; }
Expand All @@ -16,6 +17,7 @@ die() { echo -e "${RED}✗${RESET} $*" >&2; exit 1; }
# ── Flag parsing ─────────────────────────────────────────────────────────────
DRY_RUN=false
NO_INIT=false
CHECK_PATH_PERSISTENCE=false
VERSION=""

usage() {
Expand All @@ -33,6 +35,8 @@ Flags:
config separately.
--version VER Pin a specific version (e.g. 2026.04.24.4) instead of
installing the latest. Applies to uv / pipx / pip.
--check-path-persistence
Check shell-profile PATH persistence logic, then exit.
Comment thread
0xmariowu marked this conversation as resolved.
-h, --help Show this help.
EOF
}
Expand All @@ -41,6 +45,7 @@ while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--no-init) NO_INIT=true; shift ;;
--check-path-persistence) CHECK_PATH_PERSISTENCE=true; shift ;;
--version) [[ -z "${2:-}" ]] && die "--version requires an argument"; VERSION="$2"; shift 2 ;;
--version=*) VERSION="${1#--version=}"; shift ;;
-h|--help) usage; exit 0 ;;
Expand Down Expand Up @@ -130,24 +135,46 @@ fix_path() {
}

# ── 4. Persist PATH to shell profile ─────────────────────────────────────────
shell_profile() {
case "$(basename "${SHELL:-}")" in
zsh) echo "$HOME/.zshrc" ;;
bash) echo "$HOME/.bashrc" ;;
Comment thread
0xmariowu marked this conversation as resolved.
Outdated
*) echo "$HOME/.profile" ;;
esac
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

persist_path() {
local line='export PATH="$HOME/.local/bin:$PATH"'
for profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do
if [ -f "$profile" ] && ! grep -q '.local/bin' "$profile" 2>/dev/null; then
if [[ "$DRY_RUN" == true ]]; then
printf " [dry-run] would append PATH export to %s\n" "$profile"
else
echo "" >> "$profile"
echo "# Added by AutoSearch installer" >> "$profile"
echo "$line" >> "$profile"
warn "Added ~/.local/bin to PATH in $profile"
fi
break
fi
done
local profile

if [[ ":$ORIGINAL_PATH:" == *":$HOME/.local/bin:"* ]]; then
Comment thread
0xmariowu marked this conversation as resolved.
return 0
fi

profile="$(shell_profile)"
if [[ -f "$profile" ]] && grep -Fqx "$line" "$profile" 2>/dev/null; then
return 0
fi

if [[ "$DRY_RUN" == true ]]; then
printf " [dry-run] would append PATH export to %s\n" "$profile"
return 0
fi

mkdir -p "$(dirname "$profile")"
touch "$profile"
echo "" >> "$profile"
echo "# Added by AutoSearch installer" >> "$profile"
echo "$line" >> "$profile"
warn "Added ~/.local/bin to PATH in $profile"
Comment thread
0xmariowu marked this conversation as resolved.
}

# ── Main ─────────────────────────────────────────────────────────────────────
if [[ "$CHECK_PATH_PERSISTENCE" == true ]]; then
persist_path
exit 0
fi

install_autosearch
fix_path

Expand All @@ -163,8 +190,9 @@ if [[ "$DRY_RUN" == true ]]; then
exit 0
fi

persist_path

if ! command -v autosearch &>/dev/null; then
persist_path
fix_path
fi

Expand Down
84 changes: 84 additions & 0 deletions tests/smoke/test_npm_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import shutil
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -67,3 +68,86 @@ def test_npm_pack_dry_run_has_no_install_lifecycle_scripts(
packed_files = {entry["path"] for entry in pack_output[0]["files"]}
assert "package.json" in packed_files
assert "bin/autosearch-ai.js" in packed_files


def test_spawn_enoent_returns_nonzero(tmp_path: Path) -> None:
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
fake_autosearch = fake_bin / "autosearch"
fake_autosearch.write_text(
"#!/bin/sh\n"
'if [ "$1" = "--version" ]; then\n'
f' rm -f "{fake_autosearch}"\n'
' echo "2026.4.24.1"\n'
" exit 0\n"
"fi\n"
'echo "unexpected autosearch invocation" >&2\n'
"exit 2\n",
encoding="utf-8",
)
fake_autosearch.chmod(0o755)

env = os.environ.copy()
env["PATH"] = os.pathsep.join([str(fake_bin), "/usr/bin", "/bin"])
Comment thread
0xmariowu marked this conversation as resolved.

result = subprocess.run(
[
shutil.which("node") or "node",
str(NPM_DIR / "bin" / "autosearch-ai.js"),
"doctor",
],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=30,
check=False,
)

assert result.returncode != 0
assert "autosearch not found" in result.stderr.lower()
assert "path" in result.stderr.lower()


def test_path_after_install_finds_binary(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
fake_bash = fake_bin / "bash"
fake_bash.write_text(
"#!/bin/sh\n"
'mkdir -p "$HOME/.local/bin"\n'
Comment thread
0xmariowu marked this conversation as resolved.
'cat > "$HOME/.local/bin/autosearch" <<\'EOF\'\n'
Comment thread
0xmariowu marked this conversation as resolved.
Outdated
"#!/bin/sh\n"
'echo "installed autosearch $*"\n'
"exit 0\n"
"EOF\n"
'chmod +x "$HOME/.local/bin/autosearch"\n'
Comment thread
0xmariowu marked this conversation as resolved.
"exit 0\n",
encoding="utf-8",
)
fake_bash.chmod(0o755)

env = os.environ.copy()
env["HOME"] = str(home)
env["PATH"] = os.pathsep.join([str(fake_bin), "/usr/bin", "/bin"])

result = subprocess.run(
[
shutil.which("node") or "node",
str(NPM_DIR / "bin" / "autosearch-ai.js"),
"--yes",
"doctor",
],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=30,
check=False,
)

combined_output = result.stdout + result.stderr
assert result.returncode == 0, combined_output
assert "installed autosearch doctor" in result.stdout
47 changes: 47 additions & 0 deletions tests/unit/test_install_sh_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import os
import subprocess
from pathlib import Path

Expand Down Expand Up @@ -69,3 +70,49 @@ def test_unknown_flag_is_rejected() -> None:
result = _run("--definitely-not-a-flag")
assert result.returncode != 0, "unknown flag must exit non-zero"
assert "unknown flag" in result.stderr.lower() or "unknown flag" in result.stdout.lower()


def test_install_persists_path_when_not_in_original(tmp_path: Path) -> None:
profile = tmp_path / ".zshrc"
profile.write_text("# existing profile\n", encoding="utf-8")

env = os.environ.copy()
env["HOME"] = str(tmp_path)
env["PATH"] = "/usr/bin:/bin"
env["SHELL"] = "/bin/zsh"

result = subprocess.run(
["bash", str(INSTALL_SH), "--check-path-persistence"],
capture_output=True,
text=True,
timeout=30,
env=env,
)

expected_line = 'export PATH="$HOME/.local/bin:$PATH"'
Comment thread
0xmariowu marked this conversation as resolved.
combined_output = result.stdout + result.stderr
assert result.returncode == 0, combined_output
assert expected_line in profile.read_text(encoding="utf-8")

already_configured_home = tmp_path / "already-configured"
already_configured_home.mkdir()
already_configured_profile = already_configured_home / ".zshrc"
original_profile = "# existing profile\n"
already_configured_profile.write_text(original_profile, encoding="utf-8")

env["HOME"] = str(already_configured_home)
env["PATH"] = os.pathsep.join(
[str(already_configured_home / ".local" / "bin"), "/usr/bin", "/bin"]
Comment thread
0xmariowu marked this conversation as resolved.
)

result = subprocess.run(
["bash", str(INSTALL_SH), "--check-path-persistence"],
capture_output=True,
text=True,
timeout=30,
env=env,
)

combined_output = result.stdout + result.stderr
assert result.returncode == 0, combined_output
assert already_configured_profile.read_text(encoding="utf-8") == original_profile