fix activation of devenv, use /usr/bin/env bash everywhere
This commit is contained in:
3
.github/workflows/lint.yml
vendored
3
.github/workflows/lint.yml
vendored
@@ -165,6 +165,9 @@ jobs:
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
- name: Require portable Bash shebangs
|
||||
run: python scripts/check_bash_shebangs.py
|
||||
|
||||
# The Sep 2026 decomposition kept old import paths alive for external plugins
|
||||
# (PLUGIN-COMPAT blocks, see COMPAT_MANIFEST.md). They are removed on schedule by
|
||||
# reverting one commit, so in-tree code must never depend on them.
|
||||
|
||||
@@ -173,8 +173,13 @@ session-scoped. Assert the GUI session gets the tool **with the env var absent**
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # or: source venv/bin/activate
|
||||
source ./activate # provisions/syncs PM tools + dependencies, then activates
|
||||
```
|
||||
Select an isolated development `HERMES_HOME` and `HERMES_RUNTIME_DIR` first;
|
||||
see `website/docs/reference/package-management.md#developer-workflow`.
|
||||
PowerShell: `. .\activate.ps1`. `deactivate` restores the prior environment.
|
||||
For tests, use the independent test environment in `CONTRIBUTING.md` (or Nix);
|
||||
PM activation's `PYTHONPATH` does not survive the test runner's environment scrub.
|
||||
`scripts/run_tests.sh` probes `.venv`, then `venv`, then `$HOME/.hermes/hermes-agent/venv`
|
||||
(worktrees sharing the main checkout's venv).
|
||||
|
||||
|
||||
@@ -119,7 +119,8 @@ Use the [PM developer workflow](website/docs/reference/package-management.md#dev
|
||||
dependency changes, and test environments. Select your development
|
||||
home before setup so experimental code does not migrate production data.
|
||||
|
||||
After successful setup, activate from the repository root in each new shell.
|
||||
Activate from the repository root in each new shell. Activation runs setup's
|
||||
runtime-only path, so it provisions a fresh checkout and syncs stale dependencies.
|
||||
|
||||
Bash:
|
||||
|
||||
@@ -136,8 +137,9 @@ python hermes --version
|
||||
```
|
||||
|
||||
Run `python hermes` for this checkout, not a global `hermes` alias. PM activation
|
||||
adds installed tools and the selected dependency tree. It does not install
|
||||
packages or JS workspaces. `deactivate` restores the prior shell environment.
|
||||
syncs tools and Python dependencies before adding them to the shell. It does not
|
||||
install JS workspaces or rewrite launchers and shell configuration. `deactivate`
|
||||
restores the prior shell environment.
|
||||
|
||||
### Manual development and test environment
|
||||
|
||||
|
||||
20
activate
Normal file → Executable file
20
activate
Normal file → Executable file
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# venv-style activation for the Hermes dev environment (pm-managed tools).
|
||||
#
|
||||
@@ -8,18 +8,26 @@
|
||||
# Emits the composed pm env (PATH + tool vars) into the CURRENT shell, with
|
||||
# save/restore: `deactivate` undoes exactly what activation changed.
|
||||
#
|
||||
# Requires a completed `./setup-hermes.sh` — it never invokes uv. It runs
|
||||
# the pm store's pinned python (fallback: the repo venv) to emit the env
|
||||
# JSON, then exports it here.
|
||||
# Sync through setup before selecting the environment. PM owns freshness;
|
||||
# activation does not maintain a second dependency stamp.
|
||||
# ============================================================================
|
||||
_HERMES_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Setup runs in a child: failures must not exit or partially activate the
|
||||
# caller's shell, and activation must not republish launchers or shell config.
|
||||
if ! (
|
||||
unset PYTHONHOME PYTHONPATH VIRTUAL_ENV
|
||||
bash "$_HERMES_REPO/setup-hermes.sh" --runtime-only
|
||||
) >&2; then
|
||||
printf '%s\n' 'activate: setup failed; shell environment unchanged' >&2
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
# Guard against double-sourcing: venv activate precedent (re-activating is a
|
||||
# no-op re-save; we keep it idempotent by deactivating first).
|
||||
if [ -n "${__HERMES_ACTIVATED:-}" ]; then
|
||||
deactivate
|
||||
fi
|
||||
|
||||
_HERMES_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Bootstrap Python only emits the environment; it does not install anything.
|
||||
_hermes_repo="$_HERMES_REPO"
|
||||
_hermes_py=""
|
||||
|
||||
20
activate.ps1
Normal file → Executable file
20
activate.ps1
Normal file → Executable file
@@ -1,7 +1,23 @@
|
||||
# Source this file to apply the installed PM environment; deactivate restores it.
|
||||
# Source this file to sync and apply the PM environment; deactivate restores it.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (Test-Path function:deactivate) { deactivate }
|
||||
$repo = $PSScriptRoot
|
||||
$bootstrapSaved = @{}
|
||||
foreach ($key in @('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV')) {
|
||||
$bootstrapSaved[$key] = [Environment]::GetEnvironmentVariable($key)
|
||||
Remove-Item "env:$key" -ErrorAction SilentlyContinue
|
||||
}
|
||||
try {
|
||||
# Run separately so setup's exit/failure cannot terminate the sourced shell.
|
||||
$shell = (Get-Process -Id $PID).Path
|
||||
& $shell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$repo\setup-hermes.ps1" -RuntimeOnly | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw 'activate: setup failed; shell environment unchanged' }
|
||||
} finally {
|
||||
foreach ($key in $bootstrapSaved.Keys) {
|
||||
if ($null -eq $bootstrapSaved[$key]) { Remove-Item "env:$key" -ErrorAction SilentlyContinue }
|
||||
else { Set-Item "env:$key" $bootstrapSaved[$key] }
|
||||
}
|
||||
}
|
||||
if (Test-Path function:deactivate) { deactivate }
|
||||
$py = $null
|
||||
foreach ($candidate in @("$repo\.venv\Scripts\python.exe", "$repo\venv\Scripts\python.exe")) {
|
||||
if (Test-Path -LiteralPath $candidate) { $py = $candidate; break }
|
||||
|
||||
@@ -252,7 +252,7 @@ const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
|
||||
/// Prepare bytes for the on-disk bootstrap cache.
|
||||
///
|
||||
/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files
|
||||
/// are left unchanged — a BOM would break `#!/bin/bash`.
|
||||
/// are left unchanged — a BOM would break `#!/usr/bin/env bash`.
|
||||
pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> {
|
||||
match kind {
|
||||
ScriptKind::Ps1 => {
|
||||
@@ -429,9 +429,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prepare_cached_sh_stays_bomless() {
|
||||
let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/bin/bash\n");
|
||||
let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/usr/bin/env bash\n");
|
||||
assert!(!out.starts_with(UTF8_BOM));
|
||||
assert_eq!(out, b"#!/bin/bash\n");
|
||||
assert_eq!(out, b"#!/usr/bin/env bash\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -492,10 +492,10 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("hermes-bom-sh-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cached = dir.join("install-main.sh");
|
||||
std::fs::write(&cached, b"#!/bin/bash\n").unwrap();
|
||||
std::fs::write(&cached, b"#!/usr/bin/env bash\n").unwrap();
|
||||
|
||||
upgrade_cached_script(ScriptKind::Sh, &cached, &|_| {});
|
||||
assert_eq!(std::fs::read(&cached).unwrap(), b"#!/bin/bash\n");
|
||||
assert_eq!(std::fs::read(&cached).unwrap(), b"#!/usr/bin/env bash\n");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const boundary of ['resolution', 'manifest'] as const) {
|
||||
fs.mkdirSync(path.join(home, 'scripts'))
|
||||
fs.writeFileSync(
|
||||
path.join(home, 'scripts/install.sh'),
|
||||
`#!/bin/bash\nprintf started > "$HERMES_HOME/manifest-started"\nprintf 'manifest-pid=%s\\n' "$$"\nwhile :; do :; done\n`
|
||||
`#!/usr/bin/env bash\nprintf started > "$HERMES_HOME/manifest-started"\nprintf 'manifest-pid=%s\\n' "$$"\nwhile :; do :; done\n`
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -237,7 +237,7 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
|
||||
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
|
||||
|
||||
const lines = [
|
||||
'#!/bin/bash',
|
||||
'#!/usr/bin/env bash',
|
||||
'set -u',
|
||||
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
|
||||
'# and the app bundle are no longer in use.',
|
||||
|
||||
@@ -586,7 +586,7 @@ test.skipIf(process.platform === 'win32')(
|
||||
await mkdir(venvBin, { recursive: true })
|
||||
await symlink(python, pythonLink)
|
||||
await writeFile(entrypoint, 'import time\ntime.sleep(30)\n', 'utf8')
|
||||
await writeFile(launcher, `#!/bin/bash\nexec "${pythonLink}" "${entrypoint}" "$@"\n`, 'utf8')
|
||||
await writeFile(launcher, `#!/usr/bin/env bash\nexec "${pythonLink}" "${entrypoint}" "$@"\n`, 'utf8')
|
||||
await chmod(launcher, 0o755)
|
||||
|
||||
const backendFlags = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# =============================================================================
|
||||
# Example: Browser-Focused Data Generation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Build libfts5_cjk.so and install to ~/.hermes/lib/ (or $1).
|
||||
#
|
||||
# Uses the system sqlite3ext.h when present, else the vendored copy in
|
||||
|
||||
@@ -132,7 +132,7 @@ Check with: `sudo ufw status | grep 25565`
|
||||
### 8. Create Launch Script
|
||||
```bash
|
||||
cat > ~/start-minecraft.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
cd ~/minecraft-server/server
|
||||
java @user_jvm_args.txt @libraries/net/neoforged/neoforge/<VERSION>/unix_args.txt nogui
|
||||
EOF
|
||||
@@ -144,7 +144,7 @@ Note: For Forge (not NeoForge), the args file path differs. Check `startserver.s
|
||||
Create backup script:
|
||||
```bash
|
||||
cat > ~/minecraft-server/backup.sh << 'SCRIPT'
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
SERVER_DIR="$HOME/minecraft-server/server"
|
||||
BACKUP_DIR="$HOME/minecraft-server/backups"
|
||||
WORLD_DIR="$SERVER_DIR/world"
|
||||
|
||||
@@ -168,7 +168,7 @@ Training Progress Tracking:
|
||||
Evaluate every N training steps:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_checkpoint.sh
|
||||
|
||||
CHECKPOINT_DIR=$1
|
||||
@@ -272,7 +272,7 @@ microsoft/phi-2
|
||||
**Step 2: Run evaluations**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_all_models.sh
|
||||
|
||||
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"
|
||||
|
||||
@@ -324,7 +324,7 @@ Memory per GPU = Total Memory / TP
|
||||
|
||||
**Submit job**:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
#SBATCH --nodes=4
|
||||
#SBATCH --gpus-per-node=8
|
||||
#SBATCH --ntasks-per-node=1
|
||||
|
||||
@@ -228,7 +228,7 @@ curl http://localhost:8000/health
|
||||
|
||||
**Readiness check** (wait for model loaded):
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
until curl -f http://localhost:8000/health; do
|
||||
echo "Waiting for vLLM to be ready..."
|
||||
sleep 5
|
||||
|
||||
@@ -281,7 +281,7 @@ def monitor_job(ip: str, ssh_key_path: str, log_file: str = "train.log"):
|
||||
### Slurm job submission
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
#SBATCH --job-name=llm-training
|
||||
#SBATCH --nodes=4
|
||||
#SBATCH --ntasks-per-node=8
|
||||
|
||||
@@ -97,7 +97,7 @@ with open("custom_data.json", "w") as f:
|
||||
### Fine-tune script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Set paths
|
||||
DATA_PATH="custom_data.json"
|
||||
|
||||
@@ -207,7 +207,7 @@ trainer.fit(model, train_loader)
|
||||
|
||||
**SLURM job script**:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
#SBATCH --nodes=4
|
||||
#SBATCH --ntasks-per-node=8
|
||||
#SBATCH --gres=gpu:8
|
||||
|
||||
@@ -159,7 +159,7 @@ context_parallel_degree = 1 # Increase for long sequences
|
||||
**Step 2: Set up SLURM script**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
#SBATCH --job-name=llama70b
|
||||
#SBATCH --nodes=32
|
||||
#SBATCH --ntasks-per-node=8
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# DuckDuckGo Search Helper Script
|
||||
# Wrapper around ddgs CLI with sensible defaults
|
||||
# Usage: ./duckduckgo.sh <query> [max_results]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Usage: ./searxng.sh <query> [max_results] [engines]
|
||||
# Example: ./searxng.sh "python async" 10 "google,bing"
|
||||
|
||||
|
||||
51
scripts/check_bash_shebangs.py
Normal file
51
scripts/check_bash_shebangs.py
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Require PATH-resolved Bash in scripts, generated scripts, and examples.
|
||||
|
||||
Nix and other non-FHS environments need not provide Bash at a fixed bin path.
|
||||
Scan tracked text, including extensionless scripts and embedded shell payloads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
FIXED_BASH = re.compile(rb"#![ \t]*/(?:usr/)?bin/bash\b")
|
||||
|
||||
|
||||
def check(root: Path) -> list[str]:
|
||||
tracked = subprocess.run(
|
||||
["git", "ls-files", "-z"], cwd=root, check=True, capture_output=True,
|
||||
).stdout
|
||||
findings = []
|
||||
for name in tracked.split(b"\0"):
|
||||
if not name:
|
||||
continue
|
||||
relative = name.decode("utf-8", errors="surrogateescape")
|
||||
path = root / relative
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
if b"\0" in data:
|
||||
continue
|
||||
for number, line in enumerate(data.splitlines(), 1):
|
||||
if FIXED_BASH.search(line):
|
||||
findings.append(f"{relative}:{number}: use #!/usr/bin/env bash")
|
||||
return findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
args = parser.parse_args()
|
||||
findings = check(args.root)
|
||||
for finding in findings:
|
||||
print(finding)
|
||||
print(f"Bash shebang check: {len(findings)} violation(s)")
|
||||
return int(bool(findings))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# posix.sh -- repo-owned macOS/Linux Desktop update hand-off.
|
||||
#
|
||||
# The whole job: wait for the Desktop to exit, run `hermes update`, tell the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# repro.sh -- reproduce desktop-update paths against a sandboxed HERMES_HOME.
|
||||
#
|
||||
# Nothing here touches your real ~/.hermes or checkout. Each mode builds (or
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Kill all running Modal apps (sandboxes, deployments, etc.)
|
||||
#
|
||||
# Usage:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test a staged pm payload (`pm bundle --out`): the raw store python
|
||||
# boots hermes_cli/pm out of the staged repo snapshot (cwd at the staged
|
||||
# repo, no network, lazy installs off). The desktop's self-relative CLI
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Full A/B eval: N models x 2 arms x 9 tasks x R reps.
|
||||
#
|
||||
# Usage:
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# 3. Point you at `.\activate.ps1` - the venv-style way to put the pm env
|
||||
# (PATH + tool vars) into your current session.
|
||||
# ============================================================================
|
||||
param([switch]$RuntimeOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Write-Host ''
|
||||
@@ -87,6 +88,8 @@ try {
|
||||
}
|
||||
Write-Host 'Tools + dependencies installed (hash-verified via pm + uv.lock)' -ForegroundColor Green
|
||||
|
||||
if ($RuntimeOnly) { exit 0 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# Hermes Agent Setup Script — THE dev-environment entry point.
|
||||
# ============================================================================
|
||||
@@ -15,6 +15,14 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Activation needs only provisioning, not user-facing installation side effects.
|
||||
runtime_only=false
|
||||
case "${1:-}" in
|
||||
--runtime-only) runtime_only=true ;;
|
||||
'') ;;
|
||||
*) printf 'Unknown setup option: %s\n' "$1" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
@@ -154,6 +162,10 @@ if ! "$boot_py" -m pm.cli install; then
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} Tools + dependencies installed (hash-verified via pm + uv.lock)"
|
||||
|
||||
if [ "$runtime_only" = true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Environment file
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# p5.js Skill — Headless Render Pipeline
|
||||
# Renders a p5.js sketch to MP4 video via Puppeteer + ffmpeg
|
||||
#
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# p5.js Skill — Local Development Server
|
||||
# Serves the current directory over HTTP for loading local assets (fonts, images)
|
||||
#
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# p5.js Skill — Dependency Verification
|
||||
# Run: bash skills/creative/p5js/scripts/setup.sh
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ def _write_forking_script(tmp_path, stall_after: bool):
|
||||
script.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
sleep 300 &
|
||||
echo $! > {marker}
|
||||
{tail}
|
||||
@@ -105,7 +105,7 @@ def test_successful_hook_preserves_detached_helpers(tmp_path):
|
||||
script.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
sleep 300 > /dev/null 2>&1 < /dev/null &
|
||||
echo $! > {marker}
|
||||
exit 0
|
||||
@@ -128,7 +128,7 @@ def test_successful_hook_preserves_detached_helpers(tmp_path):
|
||||
def test_hook_child_leads_own_process_group(tmp_path):
|
||||
"""The hook child must lead its own group (killpg ownership precondition)."""
|
||||
script = tmp_path / "pgid.sh"
|
||||
script.write_text("#!/bin/bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
|
||||
script.write_text("#!/usr/bin/env bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
|
||||
script.chmod(0o755)
|
||||
|
||||
r = _spawn(_spec(str(script), timeout=10), "{}")
|
||||
@@ -142,7 +142,7 @@ def test_fast_path_contract_unchanged(tmp_path):
|
||||
"""stdin JSON delivery, stdout/stderr capture, and exit codes still work."""
|
||||
script = tmp_path / "echoer.sh"
|
||||
script.write_text(
|
||||
"#!/bin/bash\ncat\necho errline >&2\nexit 3\n"
|
||||
"#!/usr/bin/env bash\ncat\necho errline >&2\nexit 3\n"
|
||||
)
|
||||
script.chmod(0o755)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_run_job_no_agent_success_returns_script_stdout(hermes_env):
|
||||
from cron.scheduler import run_job
|
||||
|
||||
script_path = hermes_env / "scripts" / "alert.sh"
|
||||
script_path.write_text("#!/bin/bash\necho 'RAM 92% on host'\n")
|
||||
script_path.write_text("#!/usr/bin/env bash\necho 'RAM 92% on host'\n")
|
||||
|
||||
job = create_job(
|
||||
prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local"
|
||||
@@ -125,7 +125,7 @@ def test_run_job_no_agent_reloads_dotenv_before_script(hermes_env, monkeypatch):
|
||||
monkeypatch.setattr(env_loader, "load_hermes_dotenv", fake_load)
|
||||
|
||||
script_path = hermes_env / "scripts" / "probe.sh"
|
||||
script_path.write_text('#!/bin/bash\necho "ok"\n')
|
||||
script_path.write_text('#!/usr/bin/env bash\necho "ok"\n')
|
||||
|
||||
job = create_job(
|
||||
prompt=None, schedule="every 5m", script="probe.sh", no_agent=True, deliver="local"
|
||||
|
||||
@@ -20,7 +20,7 @@ def hermes_env(tmp_path, monkeypatch):
|
||||
home.mkdir()
|
||||
(home / "scripts").mkdir()
|
||||
(home / "cron").mkdir()
|
||||
(home / "scripts" / "watch.sh").write_text("#!/bin/bash\necho alert\n")
|
||||
(home / "scripts" / "watch.sh").write_text("#!/usr/bin/env bash\necho alert\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import importlib
|
||||
|
||||
@@ -28,7 +28,7 @@ def fake_gh(tmp_path, monkeypatch):
|
||||
if sys.platform.startswith("win"):
|
||||
pytest.skip("POSIX shell stub")
|
||||
gh = tmp_path / "gh"
|
||||
gh.write_text("#!/bin/bash\nsleep 1\necho posted\nexit 0\n", encoding="utf-8")
|
||||
gh.write_text("#!/usr/bin/env bash\nsleep 1\necho posted\nexit 0\n", encoding="utf-8")
|
||||
gh.chmod(gh.stat().st_mode | stat.S_IXUSR)
|
||||
monkeypatch.setenv("PATH", f"{tmp_path}{os.pathsep}{os.environ['PATH']}")
|
||||
return gh
|
||||
|
||||
@@ -385,7 +385,7 @@ class TestCronCreateLifecycleBlock:
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
scripts_dir = tmp_path / ".hermes" / "scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "restart.sh").write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
(scripts_dir / "restart.sh").write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
args = Namespace(
|
||||
cron_command="create",
|
||||
schedule="1h",
|
||||
@@ -561,7 +561,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
script = tmp_path / "delayed-ops.sh"
|
||||
script.write_text("#!/bin/bash\nsleep 45\nhermes gateway restart\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nsleep 45\nhermes gateway restart\n", encoding="utf-8")
|
||||
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
||||
|
||||
result = json.loads(tt.terminal_tool(command=f"/bin/bash {script}"))
|
||||
@@ -573,7 +573,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
script = tmp_path / "health-check.sh"
|
||||
script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nprintf 'healthy\\n'\n", encoding="utf-8")
|
||||
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
||||
|
||||
result = json.loads(tt.terminal_tool(
|
||||
@@ -699,7 +699,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
|
||||
script = tmp_path / "wrapper.sh"
|
||||
script.write_text(
|
||||
"#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
|
||||
"#!/usr/bin/env bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
|
||||
)
|
||||
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
||||
|
||||
@@ -712,7 +712,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
script = tmp_path / "relative.sh"
|
||||
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
|
||||
class _FakeEnv:
|
||||
env = {}
|
||||
@@ -732,7 +732,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
script = tmp_path / "delayed.sh"
|
||||
script.write_text("#!/bin/bash\nhermes gateway stop\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nhermes gateway stop\n", encoding="utf-8")
|
||||
script.chmod(0o700)
|
||||
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
||||
|
||||
@@ -756,7 +756,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
script = tmp_path / "options.sh"
|
||||
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
|
||||
|
||||
result = json.loads(tt.terminal_tool(
|
||||
@@ -769,7 +769,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
script = tmp_path / "nested.sh"
|
||||
script.write_text("#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n", encoding="utf-8")
|
||||
|
||||
class _FakeEnv:
|
||||
env = {}
|
||||
@@ -790,9 +790,9 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
inner = tmp_path / "inner.sh"
|
||||
inner.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
inner.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
outer = tmp_path / "outer.sh"
|
||||
outer.write_text("#!/bin/bash\n/bin/bash inner.sh\n", encoding="utf-8")
|
||||
outer.write_text("#!/usr/bin/env bash\n/bin/bash inner.sh\n", encoding="utf-8")
|
||||
|
||||
class _FakeEnv:
|
||||
env = {}
|
||||
@@ -846,7 +846,7 @@ class TestTerminalToolGatewayLifecycleGuard:
|
||||
|
||||
calls = []
|
||||
script = tmp_path / "health-check.sh"
|
||||
script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nprintf 'healthy\\n'\n", encoding="utf-8")
|
||||
|
||||
class _FakeEnv:
|
||||
env = {}
|
||||
@@ -906,7 +906,7 @@ class TestLifecycleGuardModule:
|
||||
contains_gateway_lifecycle_command_or_referenced_script,
|
||||
)
|
||||
script = tmp_path / "restart.sh"
|
||||
script.write_text("#!/bin/bash\nhermes gateway restart\n")
|
||||
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n")
|
||||
assert (
|
||||
contains_gateway_lifecycle_command_or_referenced_script(f". {script}")
|
||||
is True
|
||||
@@ -925,7 +925,7 @@ class TestLifecycleGuardModule:
|
||||
contains_gateway_lifecycle_command_or_referenced_script,
|
||||
)
|
||||
script = tmp_path / "padded.sh"
|
||||
script.write_bytes(b"#!/bin/bash\n# pad\x00\nhermes gateway restart\n")
|
||||
script.write_bytes(b"#!/usr/bin/env bash\n# pad\x00\nhermes gateway restart\n")
|
||||
assert (
|
||||
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
|
||||
is True
|
||||
@@ -938,7 +938,7 @@ class TestLifecycleGuardModule:
|
||||
contains_gateway_lifecycle_command_or_referenced_script,
|
||||
)
|
||||
script = tmp_path / "restart.sh"
|
||||
script.write_text("#!/bin/bash\nhermes gateway restart\n")
|
||||
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n")
|
||||
assert (
|
||||
contains_gateway_lifecycle_command_or_referenced_script(f"source {script}")
|
||||
is True
|
||||
@@ -951,7 +951,7 @@ class TestLifecycleGuardModule:
|
||||
contains_gateway_lifecycle_command_or_referenced_script,
|
||||
)
|
||||
script = tmp_path / "activate.sh"
|
||||
script.write_text("#!/bin/bash\nexport PATH=/usr/bin:$PATH\n")
|
||||
script.write_text("#!/usr/bin/env bash\nexport PATH=/usr/bin:$PATH\n")
|
||||
assert (
|
||||
contains_gateway_lifecycle_command_or_referenced_script(f". {script}")
|
||||
is False
|
||||
@@ -1019,7 +1019,7 @@ class TestLifecycleGuardModule:
|
||||
contains_gateway_lifecycle_command_or_referenced_script,
|
||||
)
|
||||
script = tmp_path / "huge.sh"
|
||||
script.write_bytes(b"#!/bin/bash\n# \x00" + b"x" * (1024 * 1024 + 64) + b"\n")
|
||||
script.write_bytes(b"#!/usr/bin/env bash\n# \x00" + b"x" * (1024 * 1024 + 64) + b"\n")
|
||||
assert (
|
||||
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
|
||||
is True
|
||||
@@ -1031,7 +1031,7 @@ class TestLifecycleGuardModule:
|
||||
contains_gateway_lifecycle_command_or_referenced_script,
|
||||
)
|
||||
script = tmp_path / "safe.sh"
|
||||
script.write_bytes(b"#!/bin/bash\necho hello\n")
|
||||
script.write_bytes(b"#!/usr/bin/env bash\necho hello\n")
|
||||
assert (
|
||||
contains_gateway_lifecycle_command_or_referenced_script(f"bash {script}")
|
||||
is False
|
||||
@@ -1051,7 +1051,7 @@ class TestLifecycleGuardModule:
|
||||
def test_script_with_command_raises(self, tmp_path, monkeypatch):
|
||||
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
||||
script = tmp_path / "restart.sh"
|
||||
script.write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
with pytest.raises(GatewayLifecycleBlocked):
|
||||
check_gateway_lifecycle("clean prompt", str(script))
|
||||
|
||||
@@ -1059,7 +1059,7 @@ class TestLifecycleGuardModule:
|
||||
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
||||
script = tmp_path / "persistent.sh"
|
||||
script.write_text(
|
||||
"#!/bin/bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
|
||||
"#!/usr/bin/env bash\nlaunchctl submit -l ai.hermes.loop -- /bin/true\n"
|
||||
)
|
||||
with pytest.raises(GatewayLifecycleBlocked):
|
||||
check_gateway_lifecycle("clean prompt", str(script))
|
||||
@@ -1075,7 +1075,7 @@ class TestLifecycleGuardModule:
|
||||
):
|
||||
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
||||
script = tmp_path / "persistent.sh"
|
||||
script.write_text(f"#!/bin/bash\n{line}\n", encoding="utf-8")
|
||||
script.write_text(f"#!/usr/bin/env bash\n{line}\n", encoding="utf-8")
|
||||
with pytest.raises(GatewayLifecycleBlocked):
|
||||
check_gateway_lifecycle("clean prompt", str(script))
|
||||
|
||||
@@ -1225,8 +1225,8 @@ class TestLifecycleGuardModule:
|
||||
a .sh script that itself invokes a lifecycle command is caught."""
|
||||
from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle
|
||||
script = tmp_path / "wrapper.sh"
|
||||
script.write_text("#!/bin/bash\n./deploy.sh\n", encoding="utf-8")
|
||||
(tmp_path / "deploy.sh").write_text("#!/bin/bash\nhermes gateway stop\n", encoding="utf-8")
|
||||
script.write_text("#!/usr/bin/env bash\n./deploy.sh\n", encoding="utf-8")
|
||||
(tmp_path / "deploy.sh").write_text("#!/usr/bin/env bash\nhermes gateway stop\n", encoding="utf-8")
|
||||
with pytest.raises(GatewayLifecycleBlocked):
|
||||
check_gateway_lifecycle("daily ops", str(script))
|
||||
|
||||
@@ -1884,7 +1884,7 @@ class TestTerminalToolGatewayLifecycleGuardRemote:
|
||||
def execute(self, command, **kwargs):
|
||||
calls.append(command)
|
||||
if "head -c" in command and "/remote/workspace/remote.sh" in command:
|
||||
return {"output": "#!/bin/bash\nhermes gateway restart\n", "returncode": 0}
|
||||
return {"output": "#!/usr/bin/env bash\nhermes gateway restart\n", "returncode": 0}
|
||||
return {"output": "", "returncode": 0}
|
||||
|
||||
fake_env = _RemoteEnv()
|
||||
@@ -1911,8 +1911,8 @@ class TestCronCreateLifecycleBlockExtra:
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
scripts_dir = tmp_path / ".hermes" / "scripts"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
(scripts_dir / "inner.sh").write_text("#!/bin/bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
(scripts_dir / "outer.sh").write_text("#!/bin/bash\n/bin/bash inner.sh\n", encoding="utf-8")
|
||||
(scripts_dir / "inner.sh").write_text("#!/usr/bin/env bash\nhermes gateway restart\n", encoding="utf-8")
|
||||
(scripts_dir / "outer.sh").write_text("#!/usr/bin/env bash\n/bin/bash inner.sh\n", encoding="utf-8")
|
||||
args = Namespace(
|
||||
cron_command="create",
|
||||
schedule="1h",
|
||||
|
||||
@@ -34,7 +34,7 @@ def _write_forking_script(tmp_path, marker_name="child.pid"):
|
||||
script.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
sleep 300 &
|
||||
echo $! > {marker}
|
||||
sleep 300
|
||||
@@ -84,7 +84,7 @@ def test_timeout_kills_descendants(tmp_path):
|
||||
def test_posix_spawn_uses_own_process_group(tmp_path):
|
||||
"""The probe child must lead its own process group (killpg precondition)."""
|
||||
script = tmp_path / "pgid.sh"
|
||||
script.write_text("#!/bin/bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
|
||||
script.write_text("#!/usr/bin/env bash\necho \"$$ $(ps -o pgid= -p $$ | tr -d ' ')\"\n")
|
||||
script.chmod(0o755)
|
||||
|
||||
out = bounded_git_probe([str(script)], timeout=5.0)
|
||||
|
||||
@@ -218,7 +218,7 @@ def test_exec_leaves_shell_wrapper_launchers_alone(tmp_path, xdg_home, monkeypat
|
||||
root = _make_project(tmp_path)
|
||||
hermes_bin = tmp_path / "bin" / "hermes"
|
||||
hermes_bin.parent.mkdir()
|
||||
hermes_bin.write_text('#!/bin/bash\nexec /opt/hermes/venv/bin/python "$@"\n', encoding="utf-8")
|
||||
hermes_bin.write_text('#!/usr/bin/env bash\nexec /opt/hermes/venv/bin/python "$@"\n', encoding="utf-8")
|
||||
hermes_bin.chmod(0o755)
|
||||
monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin))
|
||||
monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: [])
|
||||
@@ -287,7 +287,7 @@ def test_exec_converges_from_repo_script_argv0_to_installed_wrapper(
|
||||
repo_script.chmod(0o755)
|
||||
wrapper = tmp_path / "installed" / "bin" / "hermes"
|
||||
wrapper.parent.mkdir(parents=True)
|
||||
wrapper.write_text(f'#!/bin/bash\nexec {sys.executable} "$@"\n', encoding="utf-8")
|
||||
wrapper.write_text(f'#!/usr/bin/env bash\nexec {sys.executable} "$@"\n', encoding="utf-8")
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
# argv[0] = repo script; PATH lookup finds the installed wrapper.
|
||||
@@ -316,7 +316,7 @@ def test_exec_never_persists_a_bare_interpreter_command(
|
||||
root = _make_project(tmp_path)
|
||||
wrapper = tmp_path / "installed" / "bin" / "hermes"
|
||||
wrapper.parent.mkdir(parents=True)
|
||||
wrapper.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
|
||||
wrapper.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
interpreter = tmp_path / "uv" / "cpython-3.11.15" / "bin" / "python3.11"
|
||||
@@ -411,7 +411,7 @@ def test_exec_uses_known_wrapper_when_path_lookup_misses(
|
||||
known_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes"
|
||||
known_wrapper.parent.mkdir(parents=True)
|
||||
known_wrapper.write_text(
|
||||
f'#!/bin/bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n',
|
||||
f'#!/usr/bin/env bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
known_wrapper.chmod(0o755)
|
||||
@@ -460,7 +460,7 @@ def test_exec_rejects_known_wrapper_from_another_checkout(
|
||||
foreign_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes"
|
||||
foreign_wrapper.parent.mkdir(parents=True)
|
||||
foreign_wrapper.write_text(
|
||||
f"#!/bin/bash\nexec {other_root / 'venv' / 'bin' / 'python'} "
|
||||
f"#!/usr/bin/env bash\nexec {other_root / 'venv' / 'bin' / 'python'} "
|
||||
f'{other_root / "hermes"} "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -800,7 +800,7 @@ def test_wrapper_ownership_rejects_sibling_extensions(suffix, tmp_path):
|
||||
checkout.mkdir()
|
||||
evil = tmp_path / "evil-shim"
|
||||
evil.write_text(
|
||||
f"#!/bin/bash\n"
|
||||
f"#!/usr/bin/env bash\n"
|
||||
f"exec {checkout}{suffix}/venv/bin/python "
|
||||
f'{checkout}{suffix}/hermes "$@"\n',
|
||||
encoding="utf-8",
|
||||
@@ -829,7 +829,7 @@ def test_wrapper_ownership_accepts_shim_via_symlinked_home(tmp_path, monkeypatch
|
||||
shim = home_link / ".local" / "bin" / "hermes"
|
||||
shim.parent.mkdir(parents=True)
|
||||
shim.write_text(
|
||||
f"#!/bin/bash\n"
|
||||
f"#!/usr/bin/env bash\n"
|
||||
f"exec {lexical_checkout}/venv/bin/python "
|
||||
f'{lexical_checkout}/hermes "$@"\n',
|
||||
encoding="utf-8",
|
||||
@@ -992,7 +992,7 @@ def test_probe_accepts_shell_launcher_wrapper(tmp_path, xdg_home, monkeypatch):
|
||||
good_wrapper = xdg_home / ".local" / "bin" / "hermes"
|
||||
good_wrapper.parent.mkdir(parents=True)
|
||||
good_wrapper.write_text(
|
||||
f"#!/bin/bash\nexec {root / 'venv' / 'bin' / 'python'} "
|
||||
f"#!/usr/bin/env bash\nexec {root / 'venv' / 'bin' / 'python'} "
|
||||
f'{root / "hermes"} "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"""The venv-style activate scripts (./activate, ./activate.ps1).
|
||||
"""Run the real activation scripts with setup replaced at its process boundary.
|
||||
|
||||
`source ./activate` must put the composed pm env into the CURRENT shell
|
||||
without ever invoking uv: it runs the pm store's pinned python (fallback:
|
||||
the repo venv) to emit `python -m pm.cli env`, then exports the result,
|
||||
with a venv-activate-style `deactivate` that restores the prior state.
|
||||
These are real input->output checks against a fake store (same layout the
|
||||
pm store uses: facts.json + a python-<version>-<target> entry), following
|
||||
tests/pm conventions for faking the store.
|
||||
The isolated checkout uses the real PM environment reader and fake installed
|
||||
artifacts. Setup records each sync and publishes a selected environment; no test
|
||||
sources the working checkout or runs its installer against the developer's home.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -127,10 +123,8 @@ def _fake_store(tmp_path: Path) -> tuple[Path, Path]:
|
||||
# spawnable interpreter instead (see _spawnable_python). The wrapper
|
||||
# works even named python.exe because activate execs it through
|
||||
# bash/MSYS, which honors #!-scripts regardless of suffix.
|
||||
interpreter = entry / "bin" / (
|
||||
"python.exe" if sys.platform.startswith("win") else "python"
|
||||
)
|
||||
interpreter.parent.mkdir(parents=True)
|
||||
interpreter = entry / ("python.exe" if sys.platform.startswith("win") else "bin/python3")
|
||||
interpreter.parent.mkdir(parents=True, exist_ok=True)
|
||||
real = _spawnable_python()
|
||||
wrapper = "#!/bin/sh\nexec '%s' \"$@\"\n" % _posix(real)
|
||||
interpreter.write_text(wrapper, encoding="utf-8")
|
||||
@@ -156,8 +150,30 @@ def _fake_store(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return store, entry
|
||||
|
||||
|
||||
def _isolated_checkout(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "checkout with spaces"
|
||||
root.mkdir()
|
||||
shutil.copytree(REPO_ROOT / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__"))
|
||||
(root / "hermes_cli").mkdir()
|
||||
for relative in ("activate", "activate.ps1", "hermes_constants.py", "hermes_cli/__init__.py",
|
||||
"hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py"):
|
||||
shutil.copy2(REPO_ROOT / relative, root / relative)
|
||||
# Environment-only tests do not exercise provisioning; the runtime tests
|
||||
# replace these stubs with a publisher that records and applies each sync.
|
||||
(root / "setup-hermes.sh").write_text('test "$#" = 1 && test "$1" = --runtime-only\n', encoding="utf-8")
|
||||
(root / "setup-hermes.ps1").write_text(
|
||||
"param([switch]$RuntimeOnly)\nif (-not $RuntimeOnly) { exit 2 }\n", encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def _bash_env(store: Path) -> dict:
|
||||
env = os.environ.copy()
|
||||
env = _child_env()
|
||||
home = store.parent / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
env.update(HOME=_posix(home), USERPROFILE=str(home), HERMES_HOME=_posix(home / "hermes"))
|
||||
for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV", "BASH_ENV", "__HERMES_ACTIVATED"):
|
||||
env.pop(key, None)
|
||||
env["HERMES_RUNTIME_DIR"] = _posix(store)
|
||||
# Keep the real env out of the composed pm output so the canary export
|
||||
# is the only thing activate adds beyond the ambient environment.
|
||||
@@ -173,18 +189,11 @@ def test_bash_scripts_pass_syntax_check():
|
||||
assert result.returncode == 0, f"{script.name}: {result.stderr}"
|
||||
|
||||
|
||||
def test_activate_never_invokes_uv():
|
||||
"""The fast path must run the provisioned python directly — setup is
|
||||
the only place uv bootstrap logic lives."""
|
||||
source = ACTIVATE.read_text(encoding="utf-8")
|
||||
assert "uv run" not in source
|
||||
assert "ensure_pinned_uv" not in source
|
||||
|
||||
|
||||
def test_source_activate_exports_the_pm_env(tmp_path: Path):
|
||||
root = _isolated_checkout(tmp_path)
|
||||
store, _ = _fake_store(tmp_path)
|
||||
script = (
|
||||
f'source "{_posix(ACTIVATE)}" && '
|
||||
f'source "{_posix(root / "activate")}" && '
|
||||
f'test -n "$__HERMES_ACTIVATED" && '
|
||||
f'printf "%s" "${CANARY}"'
|
||||
)
|
||||
@@ -192,7 +201,7 @@ def test_source_activate_exports_the_pm_env(tmp_path: Path):
|
||||
[_bash(), "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_posix(REPO_ROOT),
|
||||
cwd=_posix(tmp_path),
|
||||
env=_bash_env(store),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -200,9 +209,10 @@ def test_source_activate_exports_the_pm_env(tmp_path: Path):
|
||||
|
||||
|
||||
def test_deactivate_restores_the_prior_shell(tmp_path: Path):
|
||||
root = _isolated_checkout(tmp_path)
|
||||
store, _ = _fake_store(tmp_path)
|
||||
script = (
|
||||
f'source "{_posix(ACTIVATE)}" && deactivate && '
|
||||
f'source "{_posix(root / "activate")}" && deactivate && '
|
||||
f'test -z "${{{CANARY}+set}}" && '
|
||||
f'test -z "${{__HERMES_ACTIVATED+set}}" && '
|
||||
f"! declare -F deactivate >/dev/null && "
|
||||
@@ -212,7 +222,7 @@ def test_deactivate_restores_the_prior_shell(tmp_path: Path):
|
||||
[_bash(), "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_posix(REPO_ROOT),
|
||||
cwd=_posix(tmp_path),
|
||||
env=_bash_env(store),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -220,12 +230,8 @@ def test_deactivate_restores_the_prior_shell(tmp_path: Path):
|
||||
|
||||
|
||||
def test_activate_fails_cleanly_without_a_store(tmp_path: Path):
|
||||
env = os.environ.copy()
|
||||
env["HERMES_RUNTIME_DIR"] = _posix(tmp_path / "empty-store")
|
||||
env.pop(CANARY, None)
|
||||
isolated = tmp_path / "no-install" / "activate"
|
||||
isolated.parent.mkdir()
|
||||
shutil.copy2(ACTIVATE, isolated)
|
||||
env = _bash_env(tmp_path / "empty-store")
|
||||
isolated = _isolated_checkout(tmp_path) / "activate"
|
||||
script = (
|
||||
f'source "{_posix(isolated)}" 2>/dev/null; '
|
||||
f'test $? -ne 0 && echo refused'
|
||||
@@ -234,7 +240,7 @@ def test_activate_fails_cleanly_without_a_store(tmp_path: Path):
|
||||
[_bash(), "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_posix(REPO_ROOT),
|
||||
cwd=_posix(tmp_path),
|
||||
env=env,
|
||||
)
|
||||
# Without any provisioned python the source must refuse — never
|
||||
@@ -285,56 +291,25 @@ def test_powershell_scripts_parse():
|
||||
assert result.returncode == 0, f"{script.name}: {result.stdout}{result.stderr}"
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
def test_powershell_activate_exports_and_deactivates(tmp_path: Path):
|
||||
ps = _powershell()
|
||||
if ps is None:
|
||||
pytest.skip("no PowerShell host available")
|
||||
store, _ = _fake_store(tmp_path)
|
||||
|
||||
# The store interpreter is a /bin/sh wrapper — PowerShell needs a real
|
||||
# python.exe, so stage the running interpreter (+ its DLLs/zips) into
|
||||
# the fake entry; if that does not yield a runnable python, skip.
|
||||
entry = store / json.loads(store.joinpath("facts.json").read_text())["packages"][
|
||||
"python"
|
||||
]["entry"]
|
||||
exe = Path(sys.executable)
|
||||
shim = entry / "bin" / "python.exe"
|
||||
for src in exe.parent.glob("*.dll"):
|
||||
shutil.copy2(src, entry / "bin" / src.name)
|
||||
for base in (exe.parent, Path(sys.base_prefix)):
|
||||
for zipname in ("python311.zip", "python312.zip"):
|
||||
if (base / zipname).exists():
|
||||
shutil.copy2(base / zipname, entry / "bin" / zipname)
|
||||
if (exe.parent / "Lib").is_dir():
|
||||
shutil.copytree(exe.parent / "Lib", entry / "bin" / "Lib", dirs_exist_ok=True)
|
||||
shutil.copy2(exe, shim)
|
||||
|
||||
probe = subprocess.run(
|
||||
[str(shim), "-c", "print(1)"], capture_output=True, text=True, env=_child_env()
|
||||
# Native venv redirectors resolve the base DLLs/stdlib without copying CPython.
|
||||
root = _isolated_checkout(tmp_path)
|
||||
env = _bash_env(tmp_path / "store")
|
||||
subprocess.run(
|
||||
[str(_spawnable_python()), "-m", "venv", "--without-pip", str(root / ".venv")],
|
||||
check=True, capture_output=True, env=env, timeout=60,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
pytest.skip("copied interpreter is not runnable on this host")
|
||||
|
||||
ps = _powershell()
|
||||
assert ps, "native Windows test requires PowerShell"
|
||||
result = subprocess.run(
|
||||
[
|
||||
ps,
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
f"$env:HERMES_RUNTIME_DIR = '{store}'; "
|
||||
f". '{ACTIVATE_PS1}'; "
|
||||
f"$active = $env:{CANARY}; "
|
||||
f"deactivate; "
|
||||
f"$after = $env:{CANARY}; "
|
||||
f"Write-Output ('active=' + $active + ' after=' + $after)",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
env=_child_env(),
|
||||
[ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command",
|
||||
f"$ErrorActionPreference='Stop'; $env:PYTHONPATH='caller-original'; "
|
||||
f". '{root / 'activate.ps1'}'; "
|
||||
"Write-Output ('active=' + $env:PYTHONPATH); deactivate; "
|
||||
"Write-Output ('after=' + $env:PYTHONPATH)"],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=40,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "active=env-ok" in result.stdout
|
||||
assert "after=" in result.stdout and "after=env-ok" not in result.stdout
|
||||
assert f"active={root}{os.pathsep}" in result.stdout
|
||||
assert "after=caller-original" in result.stdout
|
||||
|
||||
@@ -1,66 +1,206 @@
|
||||
"""Activation runs read-only from any cwd and restores the caller environment."""
|
||||
"""Activation syncs through setup before selecting or changing the caller's env."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.pm.test_activate_scripts import (
|
||||
_bash, _bash_env, _isolated_checkout, _posix, _powershell, _spawnable_python,
|
||||
)
|
||||
|
||||
|
||||
def _sync_checkout(tmp_path: Path):
|
||||
root = _isolated_checkout(tmp_path)
|
||||
env = _bash_env(tmp_path / "store")
|
||||
python = _spawnable_python()
|
||||
# The setup seam publishes a bootstrap and a selected generation. PM's
|
||||
# freshness algorithms have their own tests; here a warm setup call is a
|
||||
# no-op and activation must still call it, rather than cache its own answer.
|
||||
(root / "sync.py").write_text(textwrap.dedent('''\
|
||||
import json, os, pathlib, shutil, sys
|
||||
from hermes_cli.runtime_paths import runtime_facts_path, site_packages
|
||||
root = pathlib.Path(__file__).parent
|
||||
record = {"argv": sys.argv[1:], "python_env": {
|
||||
key: os.environ.get(key) for key in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV")}}
|
||||
with (root / "calls.jsonl").open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(record) + "\\n")
|
||||
assert all(value is None for value in record["python_env"].values()), record
|
||||
assert sys.argv[1:] == ["runtime-only"], record
|
||||
print("setup progress")
|
||||
if (root / "fail").exists():
|
||||
sys.exit(42)
|
||||
bootstrap = root / ".venv"
|
||||
if not bootstrap.exists():
|
||||
shutil.copytree(root / "prepared-bootstrap", bootstrap)
|
||||
generation = (root / "input").read_text(encoding="utf-8").strip()
|
||||
facts = runtime_facts_path(root)
|
||||
selected = facts.parent / "environments" / generation / "venv"
|
||||
if not selected.exists():
|
||||
site_packages(selected).mkdir(parents=True)
|
||||
(selected / "pyvenv.cfg").write_text("home = fixture", encoding="utf-8")
|
||||
facts.write_text(json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8")
|
||||
with (root / "builds").open("a", encoding="utf-8") as stream:
|
||||
stream.write(generation + "\\n")
|
||||
'''), encoding="utf-8")
|
||||
(root / "input").write_text("first", encoding="utf-8")
|
||||
if os.name == "nt":
|
||||
subprocess.run(
|
||||
[str(python), "-m", "venv", "--without-pip", str(root / "prepared-bootstrap")],
|
||||
check=True, capture_output=True, env=env, timeout=60,
|
||||
)
|
||||
else:
|
||||
binary = root / "prepared-bootstrap" / "bin" / "python"
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_text(f"#!/bin/sh\nexec {shlex.quote(str(python))} \"$@\"\n", encoding="utf-8")
|
||||
binary.chmod(0o755)
|
||||
(root / "setup-hermes.sh").write_text(
|
||||
'test "$#" = 1 && test "$1" = --runtime-only || exit 2\n'
|
||||
f'cd {shlex.quote(str(root))} || exit 3\n'
|
||||
f'exec {shlex.quote(str(python))} sync.py runtime-only\n', encoding="utf-8",
|
||||
)
|
||||
(root / "setup-hermes.ps1").write_text(
|
||||
"param([switch]$RuntimeOnly)\n"
|
||||
"if (-not $RuntimeOnly) { exit 2 }\n"
|
||||
"$ErrorActionPreference = 'Stop'\n"
|
||||
"$record = @{pid=$PID; executable=(Get-Process -Id $PID).Path; "
|
||||
"argv=[Environment]::GetCommandLineArgs()}\n"
|
||||
"$record | ConvertTo-Json -Compress | Add-Content -LiteralPath \"$PSScriptRoot\\ps-calls.jsonl\"\n"
|
||||
"Set-Location -LiteralPath $PSScriptRoot\n"
|
||||
f"& '{python}' sync.py runtime-only\nexit $LASTEXITCODE\n", encoding="utf-8",
|
||||
)
|
||||
return root, env
|
||||
|
||||
|
||||
def _assert_syncs(root: Path):
|
||||
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert calls == [{"argv": ["runtime-only"], "python_env": {
|
||||
"PYTHONHOME": None, "PYTHONPATH": None, "VIRTUAL_ENV": None,
|
||||
}}] * 3
|
||||
assert (root / "builds").read_text(encoding="utf-8").splitlines() == ["first", "second"]
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_bash_cold_sync_changed_input_and_warm_noop(tmp_path):
|
||||
root, env = _sync_checkout(tmp_path)
|
||||
assert not (root / ".venv").exists()
|
||||
script = f'''
|
||||
set -e
|
||||
export PYTHONPATH=caller-original VIRTUAL_ENV=caller-venv
|
||||
original_path="$PATH"
|
||||
source "{_posix(root / 'activate')}"
|
||||
printf '%s\\n' "$PYTHONPATH"
|
||||
printf second > "{_posix(root / 'input')}"
|
||||
source "{_posix(root / 'activate')}"
|
||||
printf '%s\\n' "$PYTHONPATH"
|
||||
source "{_posix(root / 'activate')}"
|
||||
printf '%s\\n' "$PYTHONPATH"
|
||||
test "$PWD" = "{_posix(tmp_path)}"
|
||||
deactivate
|
||||
test "$PATH" = "$original_path"
|
||||
test "$PYTHONPATH" = caller-original
|
||||
test "$VIRTUAL_ENV" = caller-venv
|
||||
'''
|
||||
run = subprocess.run([_bash(), "-c", script], cwd=tmp_path, env=env,
|
||||
capture_output=True, text=True, timeout=40)
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
first, second, warm = run.stdout.splitlines()
|
||||
assert first.startswith(str(root) + os.pathsep) and "/first/venv/" in first
|
||||
assert second.startswith(str(root) + os.pathsep) and "/second/venv/" in second
|
||||
assert warm == second
|
||||
assert run.stderr.count("setup progress") == 3
|
||||
_assert_syncs(root)
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
@pytest.mark.parametrize("already_active", [False, True])
|
||||
def test_bash_setup_failure_preserves_caller(tmp_path, already_active):
|
||||
root, env = _sync_checkout(tmp_path)
|
||||
activate = shlex.quote(str(root / "activate"))
|
||||
script = f'''
|
||||
set -e
|
||||
{f'source {activate}' if already_active else ':'}
|
||||
export PYTHONHOME=caller-home PYTHONPATH=caller-path VIRTUAL_ENV=caller-venv
|
||||
before_env=$(export -p)
|
||||
before_function=$(declare -f deactivate || :)
|
||||
before_active=${{__HERMES_ACTIVATED-unset}}
|
||||
before_cwd="$PWD"
|
||||
touch {shlex.quote(str(root / 'fail'))}
|
||||
if source {activate}; then exit 9; fi
|
||||
test "$(export -p)" = "$before_env"
|
||||
test "$(declare -f deactivate || :)" = "$before_function"
|
||||
test "${{__HERMES_ACTIVATED-unset}}" = "$before_active"
|
||||
test "$PWD" = "$before_cwd"
|
||||
{"deactivate" if already_active else ':'}
|
||||
printf preserved
|
||||
'''
|
||||
run = subprocess.run([_bash(), "-c", script], cwd=tmp_path, env=env,
|
||||
capture_output=True, text=True, timeout=40)
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
assert run.stdout == "preserved"
|
||||
calls = (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()
|
||||
assert len(calls) == (2 if already_active else 1)
|
||||
assert json.loads(calls[-1])["python_env"] == dict.fromkeys(("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV"))
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
def test_powershell_activation_roundtrip_selected_environment(tmp_path, monkeypatch):
|
||||
from hermes_cli.runtime_paths import runtime_facts_path
|
||||
from pm.paths import repo_root
|
||||
from pm.store import current_target
|
||||
from pm.lock import Lockfile
|
||||
|
||||
source = repo_root()
|
||||
root = tmp_path / "checkout"
|
||||
root.mkdir()
|
||||
shutil.copytree(source / "pm", root / "pm", ignore=shutil.ignore_patterns("__pycache__"))
|
||||
(root / "hermes_cli").mkdir()
|
||||
for relative in ("activate.ps1", "hermes_constants.py", "hermes_cli/__init__.py",
|
||||
"hermes_cli/runtime_paths.py", "hermes_cli/runtime_state.py"):
|
||||
shutil.copyfile(source / relative, root / relative)
|
||||
subprocess.run([sys._base_executable, "-m", "venv", "--without-pip", str(root / ".venv")],
|
||||
check=True, capture_output=True, timeout=60)
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
facts = runtime_facts_path(root)
|
||||
selected = facts.parent / "environments" / "a" / "venv"
|
||||
site = selected / "Lib" / "site-packages"
|
||||
site.mkdir(parents=True)
|
||||
(selected / "pyvenv.cfg").write_text("home = test", encoding="utf-8")
|
||||
facts.write_text(json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8")
|
||||
store = tmp_path / "tools"
|
||||
store.mkdir()
|
||||
lock = Lockfile(root / "pm" / "lock.json")
|
||||
target = current_target()
|
||||
entry = store / "python-test"
|
||||
entry.mkdir()
|
||||
(store / "facts.json").write_text(json.dumps({"schema": 1, "packages": {"python": {
|
||||
"entry": entry.name, "version": lock.version("python"), "target": target,
|
||||
"artifacts": [a["sha256"] for a in lock.artifacts("python", target)],
|
||||
"env": {"HERMES_ACTIVATE_CANARY": "active"},
|
||||
}}}), encoding="utf-8")
|
||||
env = dict(os.environ, HERMES_RUNTIME_DIR=str(store), PYTHONPATH="caller-original",
|
||||
PATHEXT=".COM;.EXE;.BAT;.CMD")
|
||||
def test_powershell_cold_sync_changed_input_warm_and_failure(tmp_path):
|
||||
# Native child-process and venv semantics cannot be reproduced by faking win32.
|
||||
root, env = _sync_checkout(tmp_path)
|
||||
ps = _powershell()
|
||||
assert ps, "native Windows test requires PowerShell"
|
||||
script = tmp_path / "run.ps1"
|
||||
script.write_text(
|
||||
f"$ErrorActionPreference='Stop'; . '{root / 'activate.ps1'}'; "
|
||||
"[PSCustomObject]@{canary=$env:HERMES_ACTIVATE_CANARY;pythonpath=$env:PYTHONPATH}|ConvertTo-Json -Compress; "
|
||||
"deactivate; Write-Output ('restored=' + $env:PYTHONPATH); "
|
||||
"Write-Output ('canary=' + $env:HERMES_ACTIVATE_CANARY)", encoding="utf-8",
|
||||
)
|
||||
powershell = shutil.which("powershell")
|
||||
assert powershell
|
||||
run = subprocess.run([powershell,"-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",str(script)],
|
||||
cwd=tmp_path,env=env,capture_output=True,text=True,timeout=40)
|
||||
script.write_text(f'''
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$env:PYTHONPATH = 'caller-original'
|
||||
$env:PYTHONHOME = 'caller-home'
|
||||
$env:VIRTUAL_ENV = 'caller-venv'
|
||||
$originalPath = $env:PATH
|
||||
$originalCwd = (Get-Location).Path
|
||||
. '{root / 'activate.ps1'}'
|
||||
Write-Output ('selection=' + $env:PYTHONPATH)
|
||||
Set-Content -LiteralPath '{root / 'input'}' -Value second
|
||||
. '{root / 'activate.ps1'}'
|
||||
Write-Output ('selection=' + $env:PYTHONPATH)
|
||||
. '{root / 'activate.ps1'}'
|
||||
Write-Output ('selection=' + $env:PYTHONPATH)
|
||||
Write-Output ('parent=' + $PID)
|
||||
$beforeEnv = Get-ChildItem env: | Sort-Object Name | ConvertTo-Json -Compress
|
||||
$beforeFunction = (Get-Item function:deactivate).Definition
|
||||
Set-Content -LiteralPath '{root / 'fail'}' -Value fail
|
||||
$failed = $false
|
||||
try {{ . '{root / 'activate.ps1'}' }} catch {{ $failed = $true }}
|
||||
if (-not $failed) {{ throw 'setup failure accepted' }}
|
||||
if ((Get-ChildItem env: | Sort-Object Name | ConvertTo-Json -Compress) -ne $beforeEnv) {{ throw 'env changed on failure' }}
|
||||
if ((Get-Item function:deactivate).Definition -ne $beforeFunction) {{ throw 'deactivation lost' }}
|
||||
if ((Get-Location).Path -ne $originalCwd) {{ throw 'cwd changed' }}
|
||||
deactivate
|
||||
if ($env:PATH -ne $originalPath -or $env:PYTHONPATH -ne 'caller-original' -or
|
||||
$env:PYTHONHOME -ne 'caller-home' -or $env:VIRTUAL_ENV -ne 'caller-venv') {{ throw 'restore failed' }}
|
||||
''', encoding="utf-8")
|
||||
run = subprocess.run([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
|
||||
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90)
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
lines = run.stdout.splitlines()
|
||||
record = json.loads(lines[0])
|
||||
assert record["canary"] == "active"
|
||||
assert record["pythonpath"] == str(root) + os.pathsep + str(site)
|
||||
assert lines[1:] == ["restored=caller-original", "canary="]
|
||||
selected = [line.removeprefix("selection=") for line in run.stdout.splitlines() if line.startswith("selection=")]
|
||||
assert len(selected) == 3
|
||||
assert "\\first\\venv\\" in selected[0]
|
||||
assert "\\second\\venv\\" in selected[1]
|
||||
assert selected[1] == selected[2]
|
||||
assert all(value.startswith(str(root) + os.pathsep) for value in selected)
|
||||
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert len(calls) == 4
|
||||
assert all(call["python_env"] == dict.fromkeys(("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV")) for call in calls)
|
||||
assert (root / "builds").read_text(encoding="utf-8").splitlines() == ["first", "second"]
|
||||
parent = next(line.removeprefix("parent=") for line in run.stdout.splitlines() if line.startswith("parent="))
|
||||
ps_calls = [json.loads(line) for line in (root / "ps-calls.jsonl").read_text(encoding="utf-8-sig").splitlines()]
|
||||
assert len(ps_calls) == 4
|
||||
for call in ps_calls:
|
||||
assert str(call["pid"]) != parent
|
||||
assert Path(call["executable"]).samefile(ps)
|
||||
assert [arg.lower() for arg in call["argv"][1:]] == [
|
||||
"-noprofile", "-noninteractive", "-executionpolicy", "bypass", "-file",
|
||||
str(root / "setup-hermes.ps1").lower(), "-runtimeonly",
|
||||
]
|
||||
|
||||
211
tests/pm/test_activation_setup.py
Normal file
211
tests/pm/test_activation_setup.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Bash activation crosses real setup, PM install, and uv dependency publication.
|
||||
|
||||
Only the downloaded tool payloads are fixtures: bootstrap uv locates the test
|
||||
interpreter, then delegates every dependency operation to real, offline uv.
|
||||
PM/activation/setup code is copied unmodified; all state is disposable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from pm.lock import Lockfile
|
||||
from pm.store import current_target
|
||||
from tests.pm.test_pm_core import make_tar, served # noqa: F401 -- shared HTTP fixture
|
||||
from tests.pm.test_workspace_build_inputs import _wheel
|
||||
|
||||
|
||||
pytestmark = pytest.mark.platforms("posix")
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _snapshot(paths):
|
||||
"""Notice creation, rewriting, chmod, and new children in protected locations."""
|
||||
result = {}
|
||||
for root in paths:
|
||||
for path in [root, *sorted(root.rglob("*"))]:
|
||||
if path.exists():
|
||||
info = path.stat()
|
||||
result[str(path)] = (
|
||||
stat.S_IMODE(info.st_mode), info.st_mtime_ns,
|
||||
path.read_bytes() if path.is_file() else None,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def test_activation_real_setup_pm_lifecycle(tmp_path, served):
|
||||
bash, uv = shutil.which("bash"), shutil.which("uv")
|
||||
assert bash and uv, "this integration contract requires Bash and real uv"
|
||||
interpreter = str(Path(sys._base_executable).resolve())
|
||||
core = tmp_path / "checkout with spaces"
|
||||
core.mkdir()
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
hermes_home = home / ".hermes"
|
||||
runtime = tmp_path / "runtime"
|
||||
scratch = tmp_path / "tmp"
|
||||
scratch.mkdir()
|
||||
env = {
|
||||
"PATH": os.environ["PATH"], "HOME": str(home), "HERMES_HOME": str(hermes_home),
|
||||
"HERMES_RUNTIME_DIR": str(runtime), "SHELL": bash, "TMPDIR": str(scratch),
|
||||
"XDG_CONFIG_HOME": str(home / ".config"), "XDG_CACHE_HOME": str(home / ".cache"),
|
||||
"LANG": "C.UTF-8", "PYTHONNOUSERSITE": "1", "UV_OFFLINE": "1",
|
||||
"UV_CACHE_DIR": str(home / ".cache" / "uv"), "UV_PYTHON_DOWNLOADS": "never",
|
||||
}
|
||||
for name in ("activate", "setup-hermes.sh", "hermes_constants.py", "utils.py"):
|
||||
shutil.copy2(REPO / name, core / name)
|
||||
for name in ("pm", "hermes_cli"):
|
||||
shutil.copytree(REPO / name, core / name, ignore=shutil.ignore_patterns("__pycache__"))
|
||||
# Plugin selection imports the real CLI config reader even with no plugins.
|
||||
# Supply its installed YAML dependency, not a stub parser or config module.
|
||||
import yaml
|
||||
shutil.copytree(Path(yaml.__file__).parent, core / "yaml", ignore=shutil.ignore_patterns("__pycache__"))
|
||||
|
||||
# Never copy real user files: these are deliberately public fixture sentinels.
|
||||
protected = [core / ".env", home / ".local" / "bin", hermes_home / "skills",
|
||||
home / ".bashrc", home / ".bash_profile", home / ".zshrc"]
|
||||
for path in (home / ".local" / "bin" / "hermes", hermes_home / "skills" / "keep.md",
|
||||
home / ".bashrc", home / ".bash_profile", home / ".zshrc"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("user-owned fixture\n", encoding="utf-8")
|
||||
(core / ".env.example").write_text("FIXTURE_ONLY=example\n", encoding="utf-8")
|
||||
(core / "skills").mkdir()
|
||||
(core / "skills" / "bundled.md").write_text("must not be seeded\n", encoding="utf-8")
|
||||
untouched = _snapshot(protected)
|
||||
|
||||
wheels = tmp_path / "wheels"
|
||||
wheels.mkdir()
|
||||
_wheel(wheels, "activation_dep", "1.0")
|
||||
(core / "pyproject.toml").write_text(
|
||||
'[project]\nname="activation-proof"\nversion="1"\nrequires-python=">=3.11"\n'
|
||||
'dependencies=["activation-dep==1.0"]\n[project.optional-dependencies]\nall=[]\n'
|
||||
'[tool.uv]\npackage=false\nno-index=true\n'
|
||||
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
|
||||
)
|
||||
locked = subprocess.run(
|
||||
[uv, "lock", "--offline", "--python", interpreter], cwd=core, env=env,
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert locked.returncode == 0, locked.stdout + locked.stderr
|
||||
dependency_lock = (core / "uv.lock").read_bytes()
|
||||
|
||||
docroot, base_url = served
|
||||
# Both the shell bootstrap and PM's fallback downloader stay on loopback.
|
||||
(core / "pm" / "artifact-mirror.json").write_text(
|
||||
json.dumps({"origin": base_url, "prefix": "mirror/"}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
calls = tmp_path / "uv-calls"
|
||||
uv_script = (
|
||||
f"#!{bash}\n"
|
||||
f"printf '%s\\n' \"$*\" >> {shlex.quote(str(calls))}\n"
|
||||
'if [ "$1 $2" = "python install" ]; then exit 0; fi\n'
|
||||
f'if [ "$1 $2" = "python find" ]; then printf \'%s\\n\' {shlex.quote(interpreter)}; exit 0; fi\n'
|
||||
f"exec {shlex.quote(uv)} --offline \"$@\"\n"
|
||||
)
|
||||
_, uv_digest = make_tar(docroot, "uv.tar.gz", {"uv-fixture/uv": uv_script})
|
||||
python_script = f'#!{bash}\nexec {shlex.quote(interpreter)} "$@"\n'
|
||||
lock = Lockfile(core / "pm" / "lock.json")
|
||||
# Replace shipped pins, not PM implementations or its package registry.
|
||||
lock.path.unlink()
|
||||
lock = Lockfile(lock.path)
|
||||
target = current_target()
|
||||
version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}+fixture"
|
||||
|
||||
def pin_python(revision):
|
||||
filename = f"python-{revision}.tar.gz"
|
||||
_, digest = make_tar(docroot, filename, {
|
||||
"python/bin/python3": python_script,
|
||||
"python/revision": revision,
|
||||
})
|
||||
lock.set_pin("python", version, {target: {"url": f"{base_url}/{filename}", "sha256": digest}})
|
||||
lock.save()
|
||||
return digest
|
||||
|
||||
lock.set_pin("uv", "fixture", {target: {"url": f"{base_url}/uv.tar.gz", "sha256": uv_digest}})
|
||||
first_digest = pin_python("first")
|
||||
|
||||
def activate(*, succeeds=True):
|
||||
# No `set -e`: inspect source's status and prove the caller survives.
|
||||
script = '''
|
||||
prior_path=$PATH
|
||||
prior_pythonpath=${PYTHONPATH-}
|
||||
source "$1/activate"
|
||||
status=$?
|
||||
if [ "$status" != 0 ]; then
|
||||
test "$PATH" = "$prior_path" || exit 91
|
||||
test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 92
|
||||
test -z "${__HERMES_ACTIVATED-}" || exit 93
|
||||
printf 'CALLER_SURVIVED:%s\\n' "$status"
|
||||
exit "$status"
|
||||
fi
|
||||
python3 -c 'import activation_dep, json, os; print(json.dumps({"version": activation_dep.__version__, "module": activation_dep.__file__, "pythonpath": os.environ["PYTHONPATH"]}))' || exit 94
|
||||
deactivate
|
||||
test "$PATH" = "$prior_path" || exit 95
|
||||
test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
|
||||
'''
|
||||
result = subprocess.run(
|
||||
[bash, "--noprofile", "--norc", "-c", script, "activation-test", str(core)],
|
||||
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90,
|
||||
)
|
||||
assert _snapshot(protected) == untouched
|
||||
assert (result.returncode == 0) is succeeds, result.stdout + result.stderr
|
||||
return result
|
||||
|
||||
def selection():
|
||||
records = list(hermes_home.glob("installs/*/facts.json"))
|
||||
assert len(records) == 1
|
||||
return json.loads(records[0].read_text())["packages"]["venv"]
|
||||
|
||||
def operations(name):
|
||||
return [line for line in calls.read_text().splitlines() if line.split()[0] == name]
|
||||
|
||||
cold = activate()
|
||||
first = selection()
|
||||
probe = json.loads(cold.stdout)
|
||||
assert probe["version"] == "1.0"
|
||||
assert Path(probe["module"]).is_relative_to(Path(first["environment"]))
|
||||
assert probe["pythonpath"].split(os.pathsep)[0] == str(core)
|
||||
assert len(operations("venv")) == len(operations("sync")) == 1
|
||||
facts = json.loads((runtime / "facts.json").read_text())["packages"]
|
||||
assert facts["python"]["artifacts"] == [first_digest]
|
||||
assert facts["uv"]["artifacts"] == [uv_digest]
|
||||
|
||||
# Also catch chmod of an existing .env, not just unwanted first creation.
|
||||
(core / ".env").write_text("FIXTURE_ONLY=existing\n", encoding="utf-8")
|
||||
(core / ".env").chmod(0o644)
|
||||
untouched = _snapshot(protected)
|
||||
activate()
|
||||
assert selection() == first
|
||||
assert len(operations("venv")) == len(operations("sync")) == 1
|
||||
assert len([line for line in operations("python") if line.startswith("python install ")]) == 2
|
||||
|
||||
second_digest = pin_python("second")
|
||||
activate()
|
||||
second = selection()
|
||||
assert second["stamp"] != first["stamp"]
|
||||
assert second["environment"] != first["environment"]
|
||||
assert Path(first["environment"]).is_dir()
|
||||
assert len(operations("venv")) == len(operations("sync")) == 2
|
||||
facts = json.loads((runtime / "facts.json").read_text())["packages"]
|
||||
assert facts["python"]["artifacts"] == [second_digest]
|
||||
assert (core / "uv.lock").read_bytes() == dependency_lock
|
||||
|
||||
# A real uv failure must cross PM -> setup -> source without selection or
|
||||
# caller mutation. An invalid local lock fails deterministically offline.
|
||||
(core / "uv.lock").write_text("not valid TOML [\n", encoding="utf-8")
|
||||
failed = activate(succeeds=False)
|
||||
assert "pm install failed" in failed.stderr
|
||||
assert "setup failed" in failed.stderr
|
||||
assert "CALLER_SURVIVED:" in failed.stdout
|
||||
assert selection() == second
|
||||
assert len(operations("sync")) == 3
|
||||
assert len([line for line in operations("python") if line.startswith("python install ")]) == 4
|
||||
42
tests/scripts/test_bash_shebangs.py
Normal file
42
tests/scripts/test_bash_shebangs.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""The repository shebang checker scans tracked scripts and embedded payloads."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
CHECKER = Path(__file__).resolve().parents[2] / "scripts" / "check_bash_shebangs.py"
|
||||
|
||||
|
||||
def test_checker_reports_and_clears_fixed_bash_paths(tmp_path):
|
||||
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
|
||||
fixed = "#!" + "/bin/bash"
|
||||
files = {
|
||||
"activate": fixed + "\necho ready\n",
|
||||
"example.md": "```bash\n" + fixed + "\n```\n",
|
||||
"generator.py": 'script = "' + fixed + '\\necho ready\\n"\n',
|
||||
"other.sh": "#!/bin/sh\n",
|
||||
"portable.sh": "#!/usr/bin/env bash\n",
|
||||
"usr.sh": "#! /usr/bin/" + "bash\n",
|
||||
}
|
||||
for name, text in files.items():
|
||||
(tmp_path / name).write_text(text, encoding="utf-8")
|
||||
subprocess.run(["git", "add", "--", *files], cwd=tmp_path, check=True)
|
||||
(tmp_path / "untracked.sh").write_text(fixed, encoding="utf-8")
|
||||
command = [sys.executable, str(CHECKER), "--root", str(tmp_path)]
|
||||
red = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
assert red.returncode == 1
|
||||
assert red.stdout.splitlines() == [
|
||||
"activate:1: use #!/usr/bin/env bash",
|
||||
"example.md:2: use #!/usr/bin/env bash",
|
||||
"generator.py:1: use #!/usr/bin/env bash",
|
||||
"usr.sh:1: use #!/usr/bin/env bash",
|
||||
"Bash shebang check: 4 violation(s)",
|
||||
]
|
||||
for name, text in files.items():
|
||||
(tmp_path / name).write_text(
|
||||
text.replace(fixed, "#!/usr/bin/env bash").replace("#! /usr/bin/" + "bash", "#!/usr/bin/env bash"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
green = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
assert green.returncode == 0, green.stdout + green.stderr
|
||||
assert green.stdout.strip() == "Bash shebang check: 0 violation(s)"
|
||||
@@ -13,7 +13,7 @@ def test_shim_removes_only_its_owned_profile(tmp_path, outcome):
|
||||
bin_dir.mkdir()
|
||||
browser = bin_dir / "google-chrome"
|
||||
browser.write_text(
|
||||
'#!/bin/bash\ntrap "exit 0" TERM\n'
|
||||
'#!/usr/bin/env bash\ntrap "exit 0" TERM\n'
|
||||
'for arg in "$@"; do\n'
|
||||
'case "$arg" in --user-data-dir=*) dir="${arg#--user-data-dir=}" ;; esac\n'
|
||||
'done\nmkdir -p "$dir"\nprintf "%s" "$dir" > "$HOME/launched-profile"\n'
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_unreadable_status_still_serves_a_running_state(progress):
|
||||
# Stands in for `hermes update`, and reports the stage that was on screen
|
||||
# while it ran -- the update child is the only thing that can observe the
|
||||
# window's state at the exact moment of the longest wait in the hand-off.
|
||||
FAKE_HERMES = """#!/bin/bash
|
||||
FAKE_HERMES = """#!/usr/bin/env bash
|
||||
# The hand-off probes `update --help` for --keep-stash support before the
|
||||
# real update call; answer it without consuming a counted call so the
|
||||
# exits.N mapping below still refers to actual update attempts.
|
||||
|
||||
@@ -31,9 +31,9 @@ import pytest
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
POSIX_SH = REPO_ROOT / "scripts" / "desktop-update" / "posix.sh"
|
||||
|
||||
GOOD_STUB = "#!/bin/bash\n# bootable interpreter stub\nexit 0\n"
|
||||
GOOD_STUB = "#!/usr/bin/env bash\n# bootable interpreter stub\nexit 0\n"
|
||||
BAD_STUB = (
|
||||
"#!/bin/bash\n"
|
||||
"#!/usr/bin/env bash\n"
|
||||
"echo \"Fatal Python error: init_fs_encoding: failed to get the Python "
|
||||
"codec of the filesystem encoding\" >&2\n"
|
||||
"echo \"ModuleNotFoundError: No module named 'encodings'\" >&2\n"
|
||||
@@ -42,7 +42,7 @@ BAD_STUB = (
|
||||
# Boots only when invoked via a path whose basename is exactly `python`:
|
||||
# used to force the post-heal verification probe to fail (rollback path).
|
||||
PICKY_STUB = (
|
||||
"#!/bin/bash\n"
|
||||
"#!/usr/bin/env bash\n"
|
||||
"[ \"$(basename \"$0\")\" = \"python\" ] && exit 0\n"
|
||||
"exit 1\n"
|
||||
)
|
||||
@@ -225,7 +225,7 @@ class TestHandoffSurvivesBrickAB:
|
||||
hermes = root / "venv/bin/hermes"
|
||||
_write_exe(
|
||||
hermes,
|
||||
"#!/bin/bash\n"
|
||||
"#!/usr/bin/env bash\n"
|
||||
'exec "$(cd "$(dirname "$0")" && pwd)/python3" -c "import encodings"\n',
|
||||
)
|
||||
return hermes
|
||||
|
||||
@@ -24,7 +24,7 @@ class TestParserLimitRecovery:
|
||||
assert saved.exists()
|
||||
body = saved.read_text()
|
||||
assert cmd in body
|
||||
assert body.startswith("#!/bin/bash")
|
||||
assert body.startswith("#!/usr/bin/env bash")
|
||||
assert f"bash {saved}" in r["message"]
|
||||
|
||||
def test_save_failure_falls_back_to_manual_recipe(self, monkeypatch):
|
||||
|
||||
@@ -163,7 +163,7 @@ class TestIterSkillsFiles:
|
||||
(skills_dir / "cat" / "myskill").mkdir(parents=True)
|
||||
(skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill")
|
||||
(skills_dir / "cat" / "myskill" / "scripts").mkdir()
|
||||
(skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash")
|
||||
(skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/usr/bin/env bash")
|
||||
# Add a symlink that should be filtered
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("nope")
|
||||
|
||||
@@ -71,7 +71,7 @@ def _save_blocked_payload(command: str) -> str | None:
|
||||
old.unlink()
|
||||
path = script_dir / f"blocked-{int(time.time())}-{uuid.uuid4().hex[:8]}.sh"
|
||||
path.write_text(
|
||||
"#!/bin/bash\n"
|
||||
"#!/usr/bin/env bash\n"
|
||||
"# Auto-saved by Hermes: this command exceeded the inline command\n"
|
||||
"# parser limit and was blocked from direct execution. Review it,\n"
|
||||
f"# then run it via: bash {path}\n" + command + ("" if command.endswith("\n") else "\n"),
|
||||
|
||||
@@ -122,9 +122,9 @@ requires trust in that plugin and its dependencies.
|
||||
|
||||
## Developer workflow {#developer-workflow}
|
||||
|
||||
PM prepares the toolchain for a source checkout. Activation makes that installed
|
||||
toolchain available in a shell. Neither operation selects your editor's Python
|
||||
interpreter or redirects an installed desktop app to this checkout.
|
||||
Activation asks PM to prepare or sync the toolchain for a source checkout, then
|
||||
makes it available in the shell. It does not select your editor's Python
|
||||
interpreter or redirect an installed desktop app to this checkout.
|
||||
|
||||
### Prepare a checkout
|
||||
|
||||
@@ -149,7 +149,6 @@ Bash, from the repository root:
|
||||
```bash
|
||||
export HERMES_HOME="$HOME/hermes-dev-data"
|
||||
export HERMES_RUNTIME_DIR="$HERMES_HOME/tools"
|
||||
bash setup-hermes.sh
|
||||
source ./activate
|
||||
```
|
||||
|
||||
@@ -158,14 +157,16 @@ PowerShell, from the repository root:
|
||||
```powershell
|
||||
$env:HERMES_HOME = Join-Path $HOME 'hermes-dev-data'
|
||||
$env:HERMES_RUNTIME_DIR = Join-Path $env:HERMES_HOME 'tools'
|
||||
.\setup-hermes.ps1
|
||||
. .\activate.ps1
|
||||
```
|
||||
|
||||
`HERMES_RUNTIME_DIR` in these examples is a process-local development override.
|
||||
It makes the bootstrap and PM use the same writable store. Do not persist a
|
||||
path into an installed MSIX or macOS bundle. The setup scripts provision tools
|
||||
and the `all` Python extra. They do not select `dev` or install JS workspaces.
|
||||
path into an installed MSIX or macOS bundle. Activation runs the setup script's
|
||||
runtime-only path to provision tools and sync the `all` Python extra. It does
|
||||
not select `dev` or install JS workspaces. It also skips setup's user-facing
|
||||
installation work: shell configuration, launchers, `.env`, and bundled skills.
|
||||
Run the setup script separately if you want that full installation workflow.
|
||||
|
||||
The bootstrap uses uv to install and locate Python, then waits for uv to exit.
|
||||
That Python runs PM directly. PM can then replace its uv entry without a running
|
||||
@@ -175,7 +176,7 @@ PyYAML, including when dependency installation fails.
|
||||
### Activate an existing installation
|
||||
|
||||
In each new shell, restore your development-home values and enter the checkout.
|
||||
Then activate it without running installation again:
|
||||
Then activate it; there is no separate setup command to remember:
|
||||
|
||||
| Shell | Enter | Leave |
|
||||
|---|---|---|
|
||||
@@ -187,9 +188,14 @@ The leading dot and space in PowerShell are required. Executing
|
||||
The POSIX script uses Bash syntax. Use Bash for this recipe rather than `sh`,
|
||||
fish, or assuming that a Zsh startup file has Bash semantics.
|
||||
|
||||
Activation prepends installed PM tools to `PATH`. It sets `PYTHONPATH` to this
|
||||
checkout and its selected dependency tree. It does not download packages,
|
||||
change an OS-wide PATH, or activate a conventional venv prompt.
|
||||
Each activation invokes PM's install/sync path. PM reuses current tools and
|
||||
dependency generations; missing or stale inputs can require downloads and a
|
||||
rebuild. A setup failure returns an error before changing the activated shell
|
||||
environment, including when re-sourcing an already active environment.
|
||||
|
||||
After sync, activation prepends installed PM tools to `PATH` and sets
|
||||
`PYTHONPATH` to this checkout and its selected dependency tree. It does not
|
||||
change an OS-wide PATH or activate a conventional venv prompt.
|
||||
Start in a clean shell rather than nesting this inside another venv.
|
||||
`deactivate` restores the environment values captured by the activation script.
|
||||
It does not uninstall packages or stop processes that you started.
|
||||
@@ -240,13 +246,15 @@ requirements in the [desktop build guide](https://github.com/NousResearch/hermes
|
||||
|
||||
### Refresh dependencies without changing branches
|
||||
|
||||
After a branch or lockfile change, prepare dependencies with this checkout's PM:
|
||||
After a branch or lockfile change, source the activation script again to sync
|
||||
and select the new dependencies (`source ./activate` in Bash or
|
||||
`. .\activate.ps1` in PowerShell). To sync without activating a shell:
|
||||
|
||||
```bash
|
||||
python -m pm.cli install
|
||||
```
|
||||
|
||||
Then leave and reactivate the environment, and restart affected processes.
|
||||
After a standalone sync, reactivate the environment. Restart affected processes.
|
||||
Use `python -m pm.cli doctor` for tool diagnostics and `python -m pm.cli status`
|
||||
for the latest sync receipt. Do not run `hermes update` just to refresh a
|
||||
feature branch: it is an application update and can change the source branch.
|
||||
|
||||
@@ -1055,7 +1055,7 @@ The `wakeAgent` gate gives you a $0 way to decide whether a scheduled job should
|
||||
**File-change gate** — only run when a watched file has new content since the last successful tick. The scheduler records each job's `last_run_at`; compare it against the file's mtime.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# ~/.hermes/scripts/feed-changed.sh
|
||||
FEED="$HOME/data/feed.json"
|
||||
STATE="$HOME/.hermes/scripts/.feed-changed.last"
|
||||
@@ -1080,7 +1080,7 @@ cronjob(action="create", name="process-feed",
|
||||
**External-flag gate** — only run when some other process has signalled readiness (e.g. a deploy hook drops a file, a CI job sets a value in your state store).
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# ~/.hermes/scripts/flag-ready.sh
|
||||
if test -f /tmp/new-data-ready; then
|
||||
rm -f /tmp/new-data-ready
|
||||
|
||||
@@ -185,7 +185,7 @@ Training Progress Tracking:
|
||||
Evaluate every N training steps:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_checkpoint.sh
|
||||
|
||||
CHECKPOINT_DIR=$1
|
||||
@@ -289,7 +289,7 @@ microsoft/phi-2
|
||||
**Step 2: Run evaluations**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_all_models.sh
|
||||
|
||||
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"
|
||||
|
||||
@@ -151,7 +151,7 @@ Check with: `sudo ufw status | grep 25565`
|
||||
### 8. Create Launch Script
|
||||
```bash
|
||||
cat > ~/start-minecraft.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
cd ~/minecraft-server/server
|
||||
java @user_jvm_args.txt @libraries/net/neoforged/neoforge/<VERSION>/unix_args.txt nogui
|
||||
EOF
|
||||
@@ -163,7 +163,7 @@ Note: For Forge (not NeoForge), the args file path differs. Check `startserver.s
|
||||
Create backup script:
|
||||
```bash
|
||||
cat > ~/minecraft-server/backup.sh << 'SCRIPT'
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
SERVER_DIR="$HOME/minecraft-server/server"
|
||||
BACKUP_DIR="$HOME/minecraft-server/backups"
|
||||
WORLD_DIR="$SERVER_DIR/world"
|
||||
|
||||
@@ -185,7 +185,7 @@ Training Progress Tracking:
|
||||
Evaluate every N training steps:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_checkpoint.sh
|
||||
|
||||
CHECKPOINT_DIR=$1
|
||||
@@ -289,7 +289,7 @@ microsoft/phi-2
|
||||
**Step 2: Run evaluations**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_all_models.sh
|
||||
|
||||
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"
|
||||
|
||||
@@ -176,7 +176,7 @@ context_parallel_degree = 1 # Increase for long sequences
|
||||
**Step 2: Set up SLURM script**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
#SBATCH --job-name=llama70b
|
||||
#SBATCH --nodes=32
|
||||
#SBATCH --ntasks-per-node=8
|
||||
|
||||
@@ -651,7 +651,7 @@ print(json.dumps({"wakeAgent": True, "context": {"new_issues": latest - prev}}))
|
||||
**文件变更门控**——仅在被监视文件自上次成功 tick 以来有新内容时运行。调度器记录每个任务的 `last_run_at`;将其与文件的 mtime 比较。
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# ~/.hermes/scripts/feed-changed.sh
|
||||
FEED="$HOME/data/feed.json"
|
||||
STATE="$HOME/.hermes/scripts/.feed-changed.last"
|
||||
@@ -676,7 +676,7 @@ cronjob(action="create", name="process-feed",
|
||||
**外部标志门控**——仅在其他进程发出就绪信号时运行(例如,部署 hook 落下一个文件,CI 任务在状态存储中设置一个值)。
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# ~/.hermes/scripts/flag-ready.sh
|
||||
if test -f /tmp/new-data-ready; then
|
||||
rm -f /tmp/new-data-ready
|
||||
|
||||
@@ -185,7 +185,7 @@ lm_eval --model hf \
|
||||
每 N 个训练步骤评估一次:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_checkpoint.sh
|
||||
|
||||
CHECKPOINT_DIR=$1
|
||||
@@ -289,7 +289,7 @@ microsoft/phi-2
|
||||
**步骤 2:运行评估**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# eval_all_models.sh
|
||||
|
||||
TASKS="mmlu,gsm8k,hellaswag,truthfulqa"
|
||||
|
||||
@@ -165,7 +165,7 @@ context_parallel_degree = 1 # Increase for long sequences
|
||||
**步骤 2:设置 SLURM 脚本**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
#SBATCH --job-name=llama70b
|
||||
#SBATCH --nodes=32
|
||||
#SBATCH --ntasks-per-node=8
|
||||
|
||||
Reference in New Issue
Block a user