Merge pull request #655 from AIOSAI/dev
aipass install one-command bootstrap (DPLAN-0233) + hide bugged HVTrust badge + devpulse prompt clarity - aipass install: pip install aipass && aipass install → clone → setup.sh → verify → auto-launch init. Clean-room Docker proven (2 runs, exit 0). 39 tests, seedgo 30/30, @aipass suite green. - README: HVTrust badge commented out (hvtracker v4.1 grade bug, issue #109 filed) - CHANGELOG 2026-07-05 section; devpulse prompt sole-git-writer note
This commit is contained in:
@@ -9,6 +9,38 @@ PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-05]
|
||||
|
||||
### Added
|
||||
|
||||
- **`aipass install` — one-command framework bootstrap.** The missing half of
|
||||
`pip install aipass`: a single command resolves the install home (default
|
||||
`~/AIPass`), git-clones the public repo, runs `setup.sh` (venv, editable
|
||||
install, hook wiring), verifies the toolchain, and auto-launches `aipass init`
|
||||
in the same terminal — so `pip install aipass && aipass install` bootstraps
|
||||
the whole system with nobody the wiser. New auto-discovered `install.py`
|
||||
module (zero shared-code edits) with flags `--non-interactive / --path /
|
||||
--here / --no-init / --with-init / --project / --dry-run`; 39 unit tests,
|
||||
seedgo 30/30, @aipass suite green. Proven in a clean-room Docker image
|
||||
(nothing pre-baked) across two runs, both exit 0: `pip install` (local wheel)
|
||||
→ clone → `setup.sh` (17 branches registered, 13 bootstrapped, hooks wired
|
||||
into `~/.claude/settings.json`, `AIPASS_HOME` set, `drone`/`aipass` on PATH) →
|
||||
live `drone systems`, and `--with-init` chaining straight into `aipass init`
|
||||
to completion. Ships install progress bars, an install→init handoff, and
|
||||
doctor coverage. (built by @aipass, DPLAN-0233 — PyPI release bump pending)
|
||||
|
||||
### Changed
|
||||
|
||||
- **HVTrust badge temporarily hidden in the root README.** hvtracker's
|
||||
methodology v4.1 recalibration is miscomputing the grade (showing D/~10 while
|
||||
the detail-page dimensions sum to ~78); the badge is commented out until it's
|
||||
corrected. Filed upstream as hvtracker issue #109 — restore when resolved.
|
||||
|
||||
- **devpulse branch prompt — sole-git-writer clarity.** Added a note to the git
|
||||
section: because no other agent can commit, merge, or push anywhere, dirty
|
||||
cross-branch files are always someone's live WIP, safe to leave and pick up
|
||||
later — never a loose end needing handoff.
|
||||
|
||||
## [2026-07-03]
|
||||
|
||||
Post-2.6.1 cycle — **unreleased** (held for a later merge).
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
[](https://codecov.io/gh/AIOSAI/AIPass)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass)
|
||||
[](https://www.bestpractices.dev/projects/13095)
|
||||
[](https://hvtracker.net/agents/aipass)
|
||||
<!-- HVTrust badge temporarily hidden 2026-07-05: hvtracker methodology v4.1 recalibration bug is showing Grade D/~10 (dimensions on the detail page sum to ~78); correction filed as hvtracker issue #109. Restore when resolved. Was:
|
||||
[](https://hvtracker.net/agents/aipass) -->
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/logo.png" alt="AIPass" width="400" />
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"metadata": {
|
||||
"version": "2.0.0",
|
||||
"created": "2026-04-16",
|
||||
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
|
||||
"last_updated": "2026-06-02"
|
||||
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile/install built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
|
||||
"last_updated": "2026-07-05"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
@@ -91,6 +91,16 @@
|
||||
"standard": "permission_flags",
|
||||
"reason": "Assertions verify that the CLI flag name appears/absent in handoff_command output. String is in assertion context only — not a permission bypass in this file."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_install.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_install.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import the install module directly to test home resolution, clone, and setup orchestration in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_ping_sweep.py",
|
||||
"standard": "architecture",
|
||||
@@ -271,6 +281,16 @@
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass profile runs the command; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/install.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare 'aipass install' runs the bootstrap; introspection via --info (same pattern as doctor/init_flow/profile)."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/install.py",
|
||||
"standard": "modules",
|
||||
"reason": "Thin bootstrap orchestrator — preps the target/project dir (mkdir) immediately before shelling out to git clone / setup.sh / aipass init. Pre-subprocess dir prep, not business file ops; a handler adds indirection for 2 mkdir calls in a linear flow (same pattern as init_flow/doctor)."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/json/json_handler.py",
|
||||
"standard": "test_quality",
|
||||
|
||||
@@ -21,6 +21,7 @@ aipass/
|
||||
│ │ ├── handoff.py # CLI handoff (placeholder)
|
||||
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
||||
│ │ ├── init_flow.py # 10-stage guided setup
|
||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||
│ │ └── profile.py # User profile read/write
|
||||
│ ├── handlers/
|
||||
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
||||
@@ -34,7 +35,7 @@ aipass/
|
||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||
│ │ └── ui/ # Progress bars, menus, banners
|
||||
│ └── plugins/
|
||||
├── tests/ # 569 passing
|
||||
├── tests/ # 609 passing
|
||||
├── requirements.project.txt # Project-specific Python dependencies
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
@@ -53,6 +54,7 @@ aipass/
|
||||
| `aipass doctor --cross-os --e2e` | ...also runs the real Layer-2 e2e wiring suite (heavy, opt-in) |
|
||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human Layer-3 acceptance pass |
|
||||
| `aipass init` | 10-stage guided setup (resumable) |
|
||||
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init (`--no-init`/`--with-init`/`--path`/`--here`) |
|
||||
| `aipass profile` | Show/edit user profile |
|
||||
| `aipass --version` | Version |
|
||||
|
||||
@@ -74,7 +76,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
569 passing — `pytest src/aipass/aipass/tests/`
|
||||
609 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -82,4 +84,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-07-04
|
||||
Last Updated: 2026-07-05
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: progress.py
|
||||
# Description: Rich progress and glyph helpers for aipass doctor
|
||||
# Version: 1.0.0
|
||||
# Description: Rich progress and glyph helpers for aipass doctor + init
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
@@ -15,6 +15,9 @@ No bare print() — all output via logger or caller's console.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
@@ -49,6 +52,26 @@ def make_doctor_progress() -> Progress:
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def activity_spinner(description: str) -> Iterator[Progress]:
|
||||
"""Transient spinner for a blocking, non-streaming action.
|
||||
|
||||
Wrap Python work that would otherwise look frozen — scaffold build, ping
|
||||
sweep — so the user sees it is alive. Do NOT wrap a subprocess that streams
|
||||
its own output to the terminal (installer, spawn): the two renderers fight.
|
||||
Yields the Progress so a caller can retitle the task mid-flight if needed.
|
||||
|
||||
Args:
|
||||
description: What is happening, e.g. "Building project scaffold…".
|
||||
"""
|
||||
logger.info("[progress] activity spinner: %s", description)
|
||||
json_handler.log_operation("activity_spinner", {"description": description})
|
||||
prog = Progress(SpinnerColumn(), TextColumn("{task.description}"), transient=True)
|
||||
with prog:
|
||||
prog.add_task(description, total=None)
|
||||
yield prog
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHECK FORMATTER
|
||||
# =============================================================================
|
||||
@@ -81,3 +104,26 @@ def format_check(
|
||||
line = f"{line}\n{indent}[dim yellow]{remediation}[/dim yellow]"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def render_step_header(current: int, total: int, label: str, width: int = 18) -> str:
|
||||
"""Render a one-line stage header with an inline progress bar.
|
||||
|
||||
Example: 'Step 3/10 [██████░░░░░░░░░░░░] — User profile'. Pure formatting —
|
||||
a static string, so it composes with the interactive prompts between stages
|
||||
(a live Rich bar would have to stop/start around every input()).
|
||||
|
||||
Args:
|
||||
current: 1-based index of the stage now starting.
|
||||
total: Total number of stages (clamps to >= 1).
|
||||
label: Human label for the stage.
|
||||
width: Character width of the bar.
|
||||
|
||||
Returns:
|
||||
Rich markup string ready for console.print().
|
||||
"""
|
||||
total = max(total, 1)
|
||||
current = max(0, min(current, total))
|
||||
filled = round(width * current / total)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return f"[bold cyan]Step {current}/{total}[/bold cyan] [green]{bar}[/green] — [bold]{label}[/bold]"
|
||||
|
||||
@@ -39,6 +39,7 @@ from aipass.cli.apps.modules import console, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.ui.progress import activity_spinner, render_step_header
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
detect_git,
|
||||
@@ -233,7 +234,7 @@ def stage_1_welcome(dry_run: bool = False) -> Dict[str, Any]:
|
||||
if dry_run:
|
||||
console.print("[yellow]\\[dry-run][/yellow] No state will be written, no subprocesses launched.")
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 1/10[/bold cyan] — Welcome")
|
||||
console.print(render_step_header(1, TOTAL_STAGES, "Welcome"))
|
||||
_save_stage(1, dry_run=dry_run)
|
||||
return {}
|
||||
|
||||
@@ -273,7 +274,7 @@ def _print_os_gap_heads_up() -> None:
|
||||
def stage_2_system_detect(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Detect OS, Python, shell, RAM, CPU, install method, and optional tools."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 2/10[/bold cyan] — System detection")
|
||||
console.print(render_step_header(2, TOTAL_STAGES, "System detection"))
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
@@ -329,7 +330,7 @@ def stage_3_user_profile(
|
||||
) -> Dict[str, Any]:
|
||||
"""Collect user name and OS, save to profile."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 3/10[/bold cyan] — User profile")
|
||||
console.print(render_step_header(3, TOTAL_STAGES, "User profile"))
|
||||
|
||||
from aipass.aipass.apps.modules import profile as profile_mod
|
||||
|
||||
@@ -369,7 +370,7 @@ def stage_4_style_questions(
|
||||
) -> Dict[str, Any]:
|
||||
"""Ask what the user wants to do — routes tone of later stages."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 4/10[/bold cyan] — What brings you here?")
|
||||
console.print(render_step_header(4, TOTAL_STAGES, "What brings you here?"))
|
||||
|
||||
if style_override and style_override in STYLE_CHOICES:
|
||||
style = style_override
|
||||
@@ -421,7 +422,7 @@ def _handle_missing_claude(non_interactive: bool) -> None:
|
||||
|
||||
raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y")
|
||||
if raw.lower() in ("y", "yes", ""):
|
||||
console.print("[dim]Installing Claude Code...[/dim]")
|
||||
console.print("[cyan]Installing Claude Code[/cyan] [dim](this can take a minute)…[/dim]")
|
||||
if _install_claude_code():
|
||||
console.print("[green]✓[/green] Claude Code installed successfully.")
|
||||
else:
|
||||
@@ -438,7 +439,7 @@ def stage_5_tool_choice(
|
||||
) -> Dict[str, Any]:
|
||||
"""Choose CLI tool and launch flag variant."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 5/10[/bold cyan] — CLI tool choice")
|
||||
console.print(render_step_header(5, TOTAL_STAGES, "CLI tool choice"))
|
||||
|
||||
if cli_override and cli_override in CLI_CHOICES:
|
||||
cli_choice = cli_override
|
||||
@@ -480,7 +481,7 @@ def stage_5_tool_choice(
|
||||
def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Create the user's first AI agent via drone @spawn."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 6/10[/bold cyan] — Create your first agent")
|
||||
console.print(render_step_header(6, TOTAL_STAGES, "Create your first agent"))
|
||||
console.print("Let's create your first AI agent (citizen).")
|
||||
|
||||
if non_interactive:
|
||||
@@ -493,7 +494,7 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
agent_path = f"{package_dir}/{agent_name}"
|
||||
else:
|
||||
agent_path = f"src/{agent_name}"
|
||||
console.print(f"Running: [cyan]drone @spawn create {agent_path}[/cyan]")
|
||||
console.print(f"[cyan]Creating your first agent[/cyan] [dim](drone @spawn create {agent_path})…[/dim]")
|
||||
|
||||
success = False
|
||||
if dry_run:
|
||||
@@ -520,7 +521,7 @@ def stage_6_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Ping all registered branches via test-convention emails."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 7/10[/bold cyan] — Pinging agents")
|
||||
console.print(render_step_header(7, TOTAL_STAGES, "Pinging agents"))
|
||||
|
||||
from aipass.aipass.apps.handlers import ping_sweep
|
||||
|
||||
@@ -547,7 +548,8 @@ def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
"[dim] (Agents with a running session will auto-ack; new agents will time out — that's normal.)[/dim]"
|
||||
)
|
||||
|
||||
results = ping_sweep.sweep_all_branches(timeout=10)
|
||||
with activity_spinner("Pinging agents…"):
|
||||
results = ping_sweep.sweep_all_branches(timeout=10)
|
||||
|
||||
for branch, status in results.items():
|
||||
if status == "ack":
|
||||
@@ -568,7 +570,7 @@ def stage_7_ping_sweep(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
def stage_8_smoke_test(non_interactive: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Verify drone and aipass binaries are on PATH."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 8/10[/bold cyan] — Smoke test")
|
||||
console.print(render_step_header(8, TOTAL_STAGES, "Smoke test"))
|
||||
|
||||
drone_bin = shutil.which("drone")
|
||||
aipass_bin = shutil.which("aipass")
|
||||
@@ -599,7 +601,7 @@ def stage_9_handoff(
|
||||
) -> Dict[str, Any]:
|
||||
"""Launch user's chosen CLI — inline (same terminal) or new window."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 9/10[/bold cyan] — Handoff")
|
||||
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
||||
|
||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
||||
|
||||
@@ -712,7 +714,7 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo
|
||||
def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Print completion summary and drop init report."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Step 10/10[/bold cyan] — Done!")
|
||||
console.print(render_step_header(10, TOTAL_STAGES, "Done!"))
|
||||
console.print()
|
||||
console.print("[bold green]✓ Setup complete![/bold green]")
|
||||
console.print()
|
||||
@@ -795,7 +797,9 @@ def run_init(
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import init_project
|
||||
|
||||
if not dry_run:
|
||||
init_project(cwd)
|
||||
with activity_spinner("Building project scaffold…"):
|
||||
init_project(cwd)
|
||||
console.print("[green]✓[/green] Project scaffold ready")
|
||||
else:
|
||||
console.print("[yellow]\\[dry-run][/yellow] would create project scaffold")
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: install.py
|
||||
# Description: aipass install — one-command PyPI bootstrap (clone + setup + handoff)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-05
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass install — one-command bootstrap of the whole framework
|
||||
|
||||
The missing half of `pip install aipass`. pip lands the *code* in site-packages;
|
||||
this command materializes a working, writable AIPass home and wires it up:
|
||||
|
||||
1. Resolve where AIPass should live (default ~/AIPass; --here / --path to steer).
|
||||
2. Fetch the framework there (git clone of the public repo) if not already present.
|
||||
3. Run the canonical setup.sh (venv, editable install, provider-hook wiring, binaries).
|
||||
4. Verify drone/aipass are on PATH, then hand off into `aipass init run` to
|
||||
scaffold a first project (interactive default; --no-init to skip, --with-init
|
||||
to force even headless). Init targets a sibling dir, never the engine tree.
|
||||
|
||||
Each step prints a Step k/N progress header. Streaming subprocesses (git, setup.sh)
|
||||
show a header + their own output + a result line — no spinner (the two renderers
|
||||
fight, per ui/progress.activity_spinner).
|
||||
|
||||
Usage:
|
||||
aipass install # interactive, then launches init
|
||||
aipass install --non-interactive # CI/headless (~/AIPass), stops before init
|
||||
aipass install --with-init # headless AND chain into init --non-interactive
|
||||
aipass install --no-init # install the engine only, skip the handoff
|
||||
aipass install --path ~/tools/aipass # explicit home
|
||||
aipass install --project ~/proj # where the first project scaffolds
|
||||
aipass install --here # install into the current directory
|
||||
aipass install --dry-run # walk all steps, no clone/setup/launch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from aipass.cli.apps.modules import console, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.ui.progress import render_step_header
|
||||
|
||||
COMMAND = "install"
|
||||
TOTAL_STEPS = 4
|
||||
REPO_URL = "https://github.com/AIOSAI/AIPass.git"
|
||||
DEFAULT_HOME = Path.home() / "AIPass"
|
||||
# `aipass init` refuses to run inside the engine tree (its pre-flight blocks on a
|
||||
# parent registry), so the auto-handoff scaffolds the first project in a sibling.
|
||||
DEFAULT_PROJECT = Path.home() / "aipass-project"
|
||||
|
||||
# Clone can be slow on a cold network; setup.sh compiles a venv + installs deps.
|
||||
_CLONE_TIMEOUT = 600
|
||||
_SETUP_TIMEOUT = 1800
|
||||
|
||||
|
||||
def _prompt(msg: str, default: str = "") -> str:
|
||||
"""Simple input prompt with optional default (raises on Ctrl-C/EOF)."""
|
||||
display = f"{msg} [{default}]: " if default else f"{msg}: "
|
||||
try:
|
||||
val = input(display).strip()
|
||||
return val if val else default
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
||||
def _looks_like_aipass_tree(home: Path) -> bool:
|
||||
"""True if `home` already holds an AIPass source tree (idempotent re-install)."""
|
||||
if not home.is_dir():
|
||||
return False
|
||||
if (home / "setup.sh").is_file():
|
||||
return True
|
||||
return bool(list(home.glob("*_REGISTRY.json")))
|
||||
|
||||
|
||||
def _resolve_home(path: str | None, here: bool, non_interactive: bool) -> Path:
|
||||
"""Decide where AIPass lives — --here / --path / $AIPASS_HOME / prompt / default."""
|
||||
if here:
|
||||
return Path.cwd().resolve()
|
||||
if path:
|
||||
return Path(path).expanduser().resolve()
|
||||
env_home = os.environ.get("AIPASS_HOME", "").strip()
|
||||
if env_home and _looks_like_aipass_tree(Path(env_home).expanduser()):
|
||||
return Path(env_home).expanduser().resolve()
|
||||
if non_interactive:
|
||||
return DEFAULT_HOME.resolve()
|
||||
raw = _prompt("Where should AIPass live?", str(DEFAULT_HOME))
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def _clone_repo(home: Path, dry_run: bool) -> bool:
|
||||
"""git clone the public AIPass repo into `home`. Returns True on success."""
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: git clone --depth 1 {REPO_URL} {home}")
|
||||
return True
|
||||
if home.exists() and any(home.iterdir()):
|
||||
warning(f"{home} exists and is not empty — pass an empty --path, or remove it first.")
|
||||
return False
|
||||
if shutil.which("git") is None:
|
||||
warning("git not found — the installer needs git to fetch AIPass. Install git and retry.")
|
||||
return False
|
||||
home.parent.mkdir(parents=True, exist_ok=True)
|
||||
console.print("[cyan]Downloading AIPass[/cyan] [dim](git clone — this can take a minute)…[/dim]")
|
||||
try:
|
||||
proc = subprocess.run(["git", "clone", "--depth", "1", REPO_URL, str(home)], timeout=_CLONE_TIMEOUT)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
logger.warning("[install] git clone exited %s", proc.returncode)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
logger.warning("[install] git clone timed out: %s", exc)
|
||||
warning("git clone timed out.")
|
||||
return False
|
||||
|
||||
|
||||
def _run_setup(home: Path, dry_run: bool) -> bool:
|
||||
"""Run the repo's setup.sh (venv + editable install + hook wiring + binaries)."""
|
||||
setup = home / "setup.sh"
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would run: bash {setup}")
|
||||
return True
|
||||
if not setup.is_file():
|
||||
warning(f"setup.sh not found at {setup} — cannot build the environment.")
|
||||
return False
|
||||
console.print("[cyan]Building environment[/cyan] [dim](venv, dependencies, hook wiring)…[/dim]")
|
||||
try:
|
||||
proc = subprocess.run(["bash", str(setup)], cwd=str(home), timeout=_SETUP_TIMEOUT)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
logger.warning("[install] setup.sh exited %s", proc.returncode)
|
||||
warning("setup.sh reported errors — see output above.")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("[install] setup.sh failed: %s", exc)
|
||||
warning(f"setup failed: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_aipass_bin(home: Path) -> str | None:
|
||||
"""Locate the aipass binary post-setup — PATH, then home/.venv/bin, then ~/.local/bin."""
|
||||
found = shutil.which("aipass")
|
||||
if found:
|
||||
return found
|
||||
for candidate in (home / ".venv" / "bin" / "aipass", Path.home() / ".local" / "bin" / "aipass"):
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
||||
"""Report drone/aipass resolution after setup (PATH may lag in the live shell)."""
|
||||
drone = shutil.which("drone") or (
|
||||
str(home / ".venv" / "bin" / "drone") if (home / ".venv" / "bin" / "drone").is_file() else None
|
||||
)
|
||||
aipass = _resolve_aipass_bin(home)
|
||||
if drone:
|
||||
console.print(f"[green]✓[/green] drone: {drone}")
|
||||
else:
|
||||
warning("drone not found after setup — check the setup output above.")
|
||||
if aipass:
|
||||
console.print(f"[green]✓[/green] aipass: {aipass}")
|
||||
else:
|
||||
warning("aipass not found after setup — check the setup output above.")
|
||||
return {"drone": drone, "aipass": aipass}
|
||||
|
||||
|
||||
def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool:
|
||||
"""Decide whether to auto-launch init. --no-init wins; --with-init forces on.
|
||||
|
||||
Default: interactive flows chain into init ("one command, done"); headless
|
||||
flows stop at a wired engine and print the next command (safe for CI/Docker).
|
||||
"""
|
||||
if no_init:
|
||||
return False
|
||||
if with_init:
|
||||
return True
|
||||
return not non_interactive
|
||||
|
||||
|
||||
def _handoff_to_init(
|
||||
home: Path,
|
||||
aipass_bin: str | None,
|
||||
non_interactive: bool,
|
||||
dry_run: bool,
|
||||
project: str | None,
|
||||
run_it: bool,
|
||||
) -> None:
|
||||
"""Print the installed banner, then (optionally) launch init for a first project.
|
||||
|
||||
`aipass init` scaffolds a *new* project and refuses to run inside the engine
|
||||
tree (pre-flight blocks on a parent registry), so init targets a sibling
|
||||
directory (``--project`` or DEFAULT_PROJECT), never ``home``. When install ran
|
||||
headless, init is launched headless too so the whole chain stays non-blocking.
|
||||
"""
|
||||
console.print()
|
||||
console.print(f"[bold green]✓ AIPass is installed at {home}[/bold green]")
|
||||
console.print()
|
||||
console.print(" [cyan]drone systems[/cyan] [dim]# list every agent[/dim]")
|
||||
console.print(" [cyan]aipass doctor[/cyan] [dim]# check system health[/dim]")
|
||||
console.print(" [cyan]aipass init run[/cyan] [dim]# scaffold your first project on AIPass[/dim]")
|
||||
console.print()
|
||||
|
||||
if not run_it:
|
||||
console.print("[dim]Run 'aipass init run' in a fresh directory to start your first project.[/dim]")
|
||||
return
|
||||
|
||||
project_dir = _resolve_project_dir(project, non_interactive)
|
||||
if project_dir is None:
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch: aipass init run in {project_dir}")
|
||||
return
|
||||
if not aipass_bin:
|
||||
warning("Can't find the aipass binary yet — open a new terminal and run 'aipass init run'.")
|
||||
return
|
||||
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[cyan]Launching guided setup[/cyan] [dim]in {project_dir}…[/dim]")
|
||||
cmd = [aipass_bin, "init", "run"]
|
||||
if non_interactive:
|
||||
cmd.append("--non-interactive")
|
||||
try:
|
||||
subprocess.run(cmd, cwd=str(project_dir))
|
||||
except (FileNotFoundError, OSError) as exc:
|
||||
logger.warning("[install] could not launch init: %s", exc)
|
||||
warning(f"Could not launch init: {exc}. Run 'aipass init run' in {project_dir} yourself.")
|
||||
|
||||
|
||||
def _resolve_project_dir(project: str | None, non_interactive: bool) -> Path | None:
|
||||
"""Resolve the first-project directory — --project / prompt / DEFAULT_PROJECT."""
|
||||
if project:
|
||||
return Path(project).expanduser().resolve()
|
||||
if non_interactive:
|
||||
return DEFAULT_PROJECT.resolve()
|
||||
try:
|
||||
raw = _prompt("Project directory for your first project", str(DEFAULT_PROJECT))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[install] init handoff cancelled by user")
|
||||
console.print()
|
||||
return None
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def run_install(
|
||||
non_interactive: bool = False,
|
||||
path: str | None = None,
|
||||
here: bool = False,
|
||||
dry_run: bool = False,
|
||||
with_init: bool = False,
|
||||
no_init: bool = False,
|
||||
project: str | None = None,
|
||||
) -> int:
|
||||
"""Run the 4-step one-command install. Returns 0 on success, 1 on failure."""
|
||||
console.print()
|
||||
console.print("[bold cyan]AIPass — one-command install[/bold cyan]")
|
||||
if dry_run:
|
||||
console.print("[yellow]\\[dry-run][/yellow] No clone, no setup, no launch — walking the steps only.")
|
||||
|
||||
# Step 1 — resolve + fetch the framework home
|
||||
console.print()
|
||||
console.print(render_step_header(1, TOTAL_STEPS, "Preparing AIPass home"))
|
||||
try:
|
||||
home = _resolve_home(path, here, non_interactive)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("[install] cancelled at home resolution by user")
|
||||
console.print()
|
||||
warning("Cancelled.")
|
||||
return 1
|
||||
console.print(f" Home: [cyan]{home}[/cyan]")
|
||||
|
||||
if _looks_like_aipass_tree(home):
|
||||
console.print(f"[green]✓[/green] AIPass already present at {home} — skipping download")
|
||||
elif not _clone_repo(home, dry_run):
|
||||
warning("Could not fetch AIPass — aborting install.")
|
||||
return 1
|
||||
else:
|
||||
console.print(f"[green]✓[/green] AIPass downloaded to {home}")
|
||||
|
||||
# Step 2 — build the environment via setup.sh
|
||||
console.print()
|
||||
console.print(render_step_header(2, TOTAL_STEPS, "Building environment"))
|
||||
if not _run_setup(home, dry_run):
|
||||
warning("Environment build failed — aborting install.")
|
||||
return 1
|
||||
console.print("[green]✓[/green] Environment ready")
|
||||
|
||||
# Step 3 — verify the binaries landed
|
||||
console.print()
|
||||
console.print(render_step_header(3, TOTAL_STEPS, "Verifying install"))
|
||||
bins = _verify_binaries(home) if not dry_run else {"drone": "dry-run", "aipass": "dry-run"}
|
||||
|
||||
# Step 4 — hand off into init (or print next steps)
|
||||
console.print()
|
||||
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
||||
run_it = _should_run_init(non_interactive, with_init, no_init)
|
||||
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
||||
|
||||
json_handler.log_operation(
|
||||
"aipass_install",
|
||||
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the install command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass install[/bold cyan] — one-command bootstrap of AIPass")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass install[/green] [dim]# interactive, then launches init[/dim]")
|
||||
console.print(" [green]aipass install --non-interactive[/green] [dim]# CI/headless (~/AIPass), no init[/dim]")
|
||||
console.print(" [green]aipass install --path DIR[/green] [dim]# explicit home[/dim]")
|
||||
console.print(" [green]aipass install --here[/green] [dim]# install into current dir[/dim]")
|
||||
console.print(" [green]aipass install --no-init[/green] [dim]# install only, skip init[/dim]")
|
||||
console.print(" [green]aipass install --with-init[/green] [dim]# force init even when headless[/dim]")
|
||||
console.print(" [green]aipass install --project DIR[/green] [dim]# where the first project scaffolds[/dim]")
|
||||
console.print(" [green]aipass install --dry-run[/green] [dim]# walk steps, no side effects[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]STEPS:[/yellow] resolve home -> fetch -> setup.sh -> verify -> launch init")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show module info for install."""
|
||||
console.print()
|
||||
console.print("[bold cyan]install Module[/bold cyan]")
|
||||
console.print("One-command bootstrap: clone + setup.sh + verify + handoff")
|
||||
console.print()
|
||||
console.print(f"[dim]Default home: {DEFAULT_HOME}[/dim]")
|
||||
console.print(f"[dim]Source: {REPO_URL}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route install subcommands. Returns True if handled, False otherwise."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
if args and args[0] in ("--info", "info"):
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
# `aipass install` runs directly; `run` is accepted as an optional verb.
|
||||
run_args = args[1:] if args and args[0] == "run" else args
|
||||
|
||||
def _flag_value(flag: str) -> str | None:
|
||||
"""Extract the value after a named flag, or None if absent."""
|
||||
if flag not in run_args:
|
||||
return None
|
||||
idx = run_args.index(flag)
|
||||
return run_args[idx + 1] if idx + 1 < len(run_args) else None
|
||||
|
||||
non_interactive = "--non-interactive" in run_args
|
||||
dry_run = "--dry-run" in run_args
|
||||
here = "--here" in run_args
|
||||
with_init = "--with-init" in run_args
|
||||
no_init = "--no-init" in run_args
|
||||
path = _flag_value("--path")
|
||||
project = _flag_value("--project")
|
||||
|
||||
result = run_install(
|
||||
non_interactive=non_interactive,
|
||||
path=path,
|
||||
here=here,
|
||||
dry_run=dry_run,
|
||||
with_init=with_init,
|
||||
no_init=no_init,
|
||||
project=project,
|
||||
)
|
||||
json_handler.log_operation(
|
||||
"install_run",
|
||||
{"non_interactive": non_interactive, "dry_run": dry_run, "with_init": with_init, "exit": result},
|
||||
)
|
||||
sys.exit(result)
|
||||
@@ -28,8 +28,10 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
||||
GLYPH_FAIL,
|
||||
GLYPH_PASS,
|
||||
GLYPH_WARN,
|
||||
activity_spinner,
|
||||
format_check,
|
||||
make_doctor_progress,
|
||||
render_step_header,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor import handle_command, run_doctor
|
||||
|
||||
@@ -273,6 +275,36 @@ class TestProgressHelpers:
|
||||
prog = make_doctor_progress()
|
||||
assert isinstance(prog, Progress)
|
||||
|
||||
def test_render_step_header_shows_step_and_label(self) -> None:
|
||||
"""Header carries the step counter and the stage label."""
|
||||
line = render_step_header(3, 10, "User profile")
|
||||
assert "Step 3/10" in line
|
||||
assert "User profile" in line
|
||||
|
||||
def test_render_step_header_bar_fills_with_progress(self) -> None:
|
||||
"""The bar has more filled cells later in the flow, full at the end."""
|
||||
early = render_step_header(1, 10, "x", width=18)
|
||||
late = render_step_header(10, 10, "x", width=18)
|
||||
assert late.count("█") > early.count("█")
|
||||
assert late.count("█") == 18
|
||||
|
||||
def test_render_step_header_clamps_out_of_range(self) -> None:
|
||||
"""current > total and total <= 0 are clamped — no overflow, no div-by-zero."""
|
||||
over = render_step_header(15, 10, "x", width=18)
|
||||
assert over.count("█") == 18
|
||||
zero = render_step_header(0, 0, "x", width=18)
|
||||
assert "Step 0/1" in zero
|
||||
|
||||
def test_activity_spinner_yields_progress_and_runs_block(self) -> None:
|
||||
"""activity_spinner is a context manager yielding a Progress; block runs."""
|
||||
from rich.progress import Progress
|
||||
|
||||
ran = []
|
||||
with activity_spinner("doing a thing…") as prog:
|
||||
assert isinstance(prog, Progress)
|
||||
ran.append(True)
|
||||
assert ran == [True]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TestDoctorHandleCommand
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_install.py
|
||||
# Description: Tests for aipass install — one-command bootstrap (DPLAN-0233)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-05
|
||||
# Modified: 2026-07-05
|
||||
# =============================================
|
||||
|
||||
"""Tests for the aipass install module (DPLAN-0233)."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.aipass.apps.modules.install import (
|
||||
DEFAULT_HOME,
|
||||
DEFAULT_PROJECT,
|
||||
TOTAL_STEPS,
|
||||
_clone_repo,
|
||||
_handoff_to_init,
|
||||
_looks_like_aipass_tree,
|
||||
_resolve_home,
|
||||
_resolve_project_dir,
|
||||
_run_setup,
|
||||
_should_run_init,
|
||||
handle_command,
|
||||
print_help,
|
||||
print_introspection,
|
||||
run_install,
|
||||
)
|
||||
|
||||
_MOD = "aipass.aipass.apps.modules.install"
|
||||
|
||||
|
||||
class TestLooksLikeAipassTree:
|
||||
"""Detecting whether a directory already holds an AIPass source tree."""
|
||||
|
||||
def test_setup_sh_present(self, tmp_path: Path) -> None:
|
||||
"""A directory with setup.sh reads as an AIPass tree."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
assert _looks_like_aipass_tree(tmp_path) is True
|
||||
|
||||
def test_registry_present(self, tmp_path: Path) -> None:
|
||||
"""A directory with a *_REGISTRY.json reads as an AIPass tree."""
|
||||
(tmp_path / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
assert _looks_like_aipass_tree(tmp_path) is True
|
||||
|
||||
def test_empty_dir(self, tmp_path: Path) -> None:
|
||||
"""An empty directory is not an AIPass tree."""
|
||||
assert _looks_like_aipass_tree(tmp_path) is False
|
||||
|
||||
def test_missing_dir(self, tmp_path: Path) -> None:
|
||||
"""A non-existent path is not an AIPass tree."""
|
||||
assert _looks_like_aipass_tree(tmp_path / "nope") is False
|
||||
|
||||
|
||||
class TestResolveHome:
|
||||
"""Resolving the install home from flags, env, and defaults."""
|
||||
|
||||
def test_here_returns_cwd(self, tmp_path: Path) -> None:
|
||||
"""--here resolves to the current working directory."""
|
||||
with patch(f"{_MOD}.Path.cwd", return_value=tmp_path):
|
||||
assert _resolve_home(None, here=True, non_interactive=False) == tmp_path.resolve()
|
||||
|
||||
def test_explicit_path(self, tmp_path: Path) -> None:
|
||||
"""An explicit --path is expanded and resolved."""
|
||||
target = tmp_path / "tools" / "aipass"
|
||||
assert _resolve_home(str(target), here=False, non_interactive=False) == target.resolve()
|
||||
|
||||
def test_non_interactive_defaults(self) -> None:
|
||||
"""With no AIPASS_HOME, non-interactive falls back to DEFAULT_HOME."""
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": ""}, clear=False):
|
||||
assert _resolve_home(None, here=False, non_interactive=True) == DEFAULT_HOME.resolve()
|
||||
|
||||
def test_uses_valid_env(self, tmp_path: Path) -> None:
|
||||
"""A valid AIPASS_HOME pointing at a real tree is honoured."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}, clear=False):
|
||||
assert _resolve_home(None, here=False, non_interactive=True) == tmp_path.resolve()
|
||||
|
||||
def test_ignores_invalid_env(self, tmp_path: Path) -> None:
|
||||
"""An AIPASS_HOME that is not an AIPass tree is ignored for the default."""
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}, clear=False):
|
||||
assert _resolve_home(None, here=False, non_interactive=True) == DEFAULT_HOME.resolve()
|
||||
|
||||
|
||||
class TestCloneRepo:
|
||||
"""Fetching the framework into the home via git clone."""
|
||||
|
||||
def test_dry_run_no_subprocess(self, tmp_path: Path) -> None:
|
||||
"""Dry-run reports success without shelling out."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _clone_repo(tmp_path / "home", dry_run=True) is True
|
||||
run.assert_not_called()
|
||||
|
||||
def test_refuses_non_empty_dir(self, tmp_path: Path) -> None:
|
||||
"""A non-empty target is refused rather than clobbered."""
|
||||
(tmp_path / "existing.txt").write_text("x", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _clone_repo(tmp_path, dry_run=False) is False
|
||||
run.assert_not_called()
|
||||
|
||||
def test_missing_git(self, tmp_path: Path) -> None:
|
||||
"""Absent git means clone fails cleanly without calling subprocess."""
|
||||
with patch(f"{_MOD}.shutil.which", return_value=None), patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _clone_repo(tmp_path / "home", dry_run=False) is False
|
||||
run.assert_not_called()
|
||||
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
"""A zero-exit git clone returns success."""
|
||||
with (
|
||||
patch(f"{_MOD}.shutil.which", return_value="/usr/bin/git"),
|
||||
patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run,
|
||||
):
|
||||
assert _clone_repo(tmp_path / "home", dry_run=False) is True
|
||||
run.assert_called_once()
|
||||
|
||||
def test_nonzero_exit(self, tmp_path: Path) -> None:
|
||||
"""A non-zero git clone exit reports failure."""
|
||||
with (
|
||||
patch(f"{_MOD}.shutil.which", return_value="/usr/bin/git"),
|
||||
patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=1)),
|
||||
):
|
||||
assert _clone_repo(tmp_path / "home", dry_run=False) is False
|
||||
|
||||
|
||||
class TestRunSetup:
|
||||
"""Running the repo setup.sh."""
|
||||
|
||||
def test_dry_run_no_subprocess(self, tmp_path: Path) -> None:
|
||||
"""Dry-run reports success without running setup.sh."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert _run_setup(tmp_path, dry_run=True) is True
|
||||
run.assert_not_called()
|
||||
|
||||
def test_missing_script(self, tmp_path: Path) -> None:
|
||||
"""A missing setup.sh reports failure."""
|
||||
assert _run_setup(tmp_path, dry_run=False) is False
|
||||
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
"""A zero-exit setup.sh returns success."""
|
||||
(tmp_path / "setup.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
assert _run_setup(tmp_path, dry_run=False) is True
|
||||
run.assert_called_once()
|
||||
|
||||
|
||||
class TestRunInstall:
|
||||
"""The four-step orchestrator."""
|
||||
|
||||
def test_dry_run_is_side_effect_free(self) -> None:
|
||||
"""Dry-run walks all steps and touches no subprocess."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
rc = run_install(non_interactive=True, dry_run=True)
|
||||
assert rc == 0
|
||||
run.assert_not_called()
|
||||
|
||||
def test_aborts_when_clone_fails(self, tmp_path: Path) -> None:
|
||||
"""A failed fetch aborts before the setup step runs."""
|
||||
home = tmp_path / "AIPass"
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}._clone_repo", return_value=False),
|
||||
patch(f"{_MOD}._run_setup") as setup,
|
||||
):
|
||||
rc = run_install(non_interactive=True, dry_run=False)
|
||||
assert rc == 1
|
||||
setup.assert_not_called()
|
||||
|
||||
def test_full_happy_path(self, tmp_path: Path) -> None:
|
||||
"""Clone + setup + verify + next-steps returns success."""
|
||||
home = tmp_path / "AIPass"
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||
patch(f"{_MOD}._run_setup", return_value=True),
|
||||
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||
patch(f"{_MOD}._handoff_to_init") as nxt,
|
||||
):
|
||||
rc = run_install(non_interactive=True, dry_run=False)
|
||||
assert rc == 0
|
||||
nxt.assert_called_once()
|
||||
|
||||
|
||||
class TestShouldRunInit:
|
||||
"""Deciding whether the install chains into init."""
|
||||
|
||||
def test_no_init_wins(self) -> None:
|
||||
"""--no-init disables the handoff even alongside --with-init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False
|
||||
|
||||
def test_with_init_forces_headless(self) -> None:
|
||||
"""--with-init runs init even when the install was headless."""
|
||||
assert _should_run_init(non_interactive=True, with_init=True, no_init=False) is True
|
||||
|
||||
def test_headless_defaults_off(self) -> None:
|
||||
"""A plain headless install stops before init."""
|
||||
assert _should_run_init(non_interactive=True, with_init=False, no_init=False) is False
|
||||
|
||||
def test_interactive_defaults_on(self) -> None:
|
||||
"""A plain interactive install chains into init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=False, no_init=False) is True
|
||||
|
||||
|
||||
class TestResolveProjectDir:
|
||||
"""Resolving where the first project scaffolds."""
|
||||
|
||||
def test_explicit_project(self, tmp_path: Path) -> None:
|
||||
"""An explicit --project is expanded and resolved."""
|
||||
target = tmp_path / "proj"
|
||||
assert _resolve_project_dir(str(target), non_interactive=True) == target.resolve()
|
||||
|
||||
def test_headless_defaults(self) -> None:
|
||||
"""Headless with no --project falls back to DEFAULT_PROJECT."""
|
||||
assert _resolve_project_dir(None, non_interactive=True) == DEFAULT_PROJECT.resolve()
|
||||
|
||||
|
||||
class TestHandoffToInit:
|
||||
"""The init handoff step."""
|
||||
|
||||
def test_skips_when_not_running(self, tmp_path: Path) -> None:
|
||||
"""run_it=False prints next steps and launches nothing."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(tmp_path, "/x/aipass", non_interactive=True, dry_run=False, project=None, run_it=False)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_dry_run_no_subprocess(self, tmp_path: Path) -> None:
|
||||
"""Dry-run announces the launch but does not spawn init."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(
|
||||
tmp_path, "/x/aipass", non_interactive=True, dry_run=True, project=str(tmp_path / "p"), run_it=True
|
||||
)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_launches_init_headless(self, tmp_path: Path) -> None:
|
||||
"""A real headless handoff launches `aipass init run --non-interactive` in the project dir."""
|
||||
project = tmp_path / "proj"
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(
|
||||
tmp_path, "/x/aipass", non_interactive=True, dry_run=False, project=str(project), run_it=True
|
||||
)
|
||||
run.assert_called_once()
|
||||
cmd = run.call_args.args[0]
|
||||
assert cmd == ["/x/aipass", "init", "run", "--non-interactive"]
|
||||
assert run.call_args.kwargs["cwd"] == str(project)
|
||||
|
||||
def test_missing_binary_warns_not_crashes(self, tmp_path: Path) -> None:
|
||||
"""No aipass binary → warn, don't launch, don't raise."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
_handoff_to_init(
|
||||
tmp_path, None, non_interactive=True, dry_run=False, project=str(tmp_path / "p"), run_it=True
|
||||
)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Command routing for `aipass install`."""
|
||||
|
||||
def test_ignores_other_commands(self) -> None:
|
||||
"""A non-install command is not handled here."""
|
||||
assert handle_command("doctor", []) is False
|
||||
|
||||
def test_help(self) -> None:
|
||||
"""--help is handled without exiting."""
|
||||
assert handle_command("install", ["--help"]) is True
|
||||
|
||||
def test_info(self) -> None:
|
||||
"""--info is handled without exiting."""
|
||||
assert handle_command("install", ["--info"]) is True
|
||||
|
||||
def test_runs_and_exits(self) -> None:
|
||||
"""A run request calls run_install and exits with its code."""
|
||||
with patch(f"{_MOD}.run_install", return_value=0) as run:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
handle_command("install", ["--dry-run", "--non-interactive"])
|
||||
assert exc.value.code == 0
|
||||
run.assert_called_once()
|
||||
|
||||
def test_passes_path_flag(self) -> None:
|
||||
"""--path and --non-interactive are threaded into run_install."""
|
||||
target = str(Path.home() / "custom-aipass-home")
|
||||
with patch(f"{_MOD}.run_install", return_value=0) as run:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("install", ["--path", target, "--non-interactive"])
|
||||
_, kwargs = run.call_args
|
||||
assert kwargs["path"] == target
|
||||
assert kwargs["non_interactive"] is True
|
||||
|
||||
def test_passes_init_flags(self) -> None:
|
||||
"""--with-init / --no-init / --project are threaded into run_install."""
|
||||
with patch(f"{_MOD}.run_install", return_value=0) as run:
|
||||
with pytest.raises(SystemExit):
|
||||
handle_command("install", ["--with-init", "--no-init", "--project", "/x/proj"])
|
||||
_, kwargs = run.call_args
|
||||
assert kwargs["with_init"] is True
|
||||
assert kwargs["no_init"] is True
|
||||
assert kwargs["project"] == "/x/proj"
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Help/introspection render and constants hold."""
|
||||
|
||||
def test_print_help_runs(self) -> None:
|
||||
"""print_help renders without error."""
|
||||
print_help()
|
||||
|
||||
def test_print_introspection_runs(self) -> None:
|
||||
"""print_introspection renders without error."""
|
||||
print_introspection()
|
||||
|
||||
def test_total_steps_constant(self) -> None:
|
||||
"""The install flow advertises four steps."""
|
||||
assert TOTAL_STEPS == 4
|
||||
@@ -26,6 +26,8 @@ DEVPULSE — the user's primary collaborator, orchestration hub. Design, plan, d
|
||||
|
||||
Only branch with git write. Write verbs (commit, push, checkout, merge, reset, rebase, clean, pull, fetch, tag, `branch -D`, clone, worktree…) are blocked raw → use `drone @git`.
|
||||
|
||||
**Sole git-writer ⇒ dirty tree is never a loose end.** No other agent can commit, merge, stage, or push — anywhere. So uncommitted changes in ANY branch's tree (mine, @aipass's, anyone's) can only ever land via me or Patrick. Nothing races them, nothing lands them behind our backs. Dirty cross-branch files = someone's live WIP, safe to leave and pick up later on our schedule. Don't flag them as needing resolution or handoff — just note they exist. Commit only when Patrick and I decide to.
|
||||
|
||||
Read git is allowed raw — run it directly for investigation, no drone needed:
|
||||
|
||||
- Verbs: `ls-files, ls-tree, show, cat-file, rev-parse, rev-list, log, status, diff, blame, describe, for-each-ref, show-ref, symbolic-ref, shortlog, grep, archive, count-objects, var, help, version`.
|
||||
|
||||
Reference in New Issue
Block a user