diff --git a/.github/workflows/installation.yml b/.github/workflows/installation.yml new file mode 100644 index 0000000000..8d004cc417 --- /dev/null +++ b/.github/workflows/installation.yml @@ -0,0 +1,65 @@ +name: Script installation + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: installation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + installation: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Build the installation fixture + shell: bash + env: + # Qualify this commit using release-shaped metadata, without publishing it. + SETUPTOOLS_SCM_PRETEND_VERSION: "1.2.0" + run: uv build --wheel --out-dir dist/installation + + - name: Check shared installer behavior and source resolution + shell: bash + run: >- + uv run --no-sync pytest -q tests/test_installation_contract.py tests/native/test_install_sources.py + --junitxml "$RUNNER_TEMP/installation-contract.xml" + --basetemp "$RUNNER_TEMP/installation-contract-tests" + + - name: Exercise bootstrap, version selection, profiles, and persistent memory + shell: bash + env: + POWERCONTEXT_INSTALL_WHEEL: ${{ github.workspace }}/dist/installation/powercontext-1.2.0-py3-none-any.whl + run: >- + uv run --no-sync pytest -q tests/native/test_installation.py + --junitxml "$RUNNER_TEMP/installation.xml" + --basetemp "$RUNNER_TEMP/installation-tests" + + - name: Upload installation diagnostics after failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: installation-${{ matrix.os }} + path: | + ${{ runner.temp }}/installation.xml + ${{ runner.temp }}/installation-contract.xml + ${{ runner.temp }}/installation-tests/**/*.log + if-no-files-found: ignore diff --git a/.github/workflows/native-service.yml b/.github/workflows/native-service.yml index b4fae40996..ac778d2950 100644 --- a/.github/workflows/native-service.yml +++ b/.github/workflows/native-service.yml @@ -44,6 +44,8 @@ jobs: echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV" - name: Install the built distribution in a fresh virtual environment + env: + SETUPTOOLS_SCM_PRETEND_VERSION: "1.2.0" shell: bash run: | uv build --wheel @@ -58,6 +60,16 @@ jobs: --basetemp "$RUNNER_TEMP/powercontext-native-tests" tests/native/test_personal_service_lifecycle.py + - name: Exercise script installation and service reconciliation + env: + POWERCONTEXT_INSTALL_WHEEL: ${{ github.workspace }}/dist/powercontext-1.2.0-py3-none-any.whl + shell: bash + run: >- + .native-venv/bin/python -m pytest -q + --junitxml "$RUNNER_TEMP/installer-service.xml" + --basetemp "$RUNNER_TEMP/installer-service-tests" + tests/native/test_service_installation.py + - name: Report Linux lifecycle failures if: failure() run: >- @@ -76,7 +88,10 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: linux-native-service-logs - path: ${{ runner.temp }}/powercontext-native-tests/**/logs/* + path: | + ${{ runner.temp }}/powercontext-native-tests/**/logs/* + ${{ runner.temp }}/installer-service-tests/**/*.log + ${{ runner.temp }}/installer-service.xml if-no-files-found: ignore macos-launchagent: @@ -97,6 +112,8 @@ jobs: uses: ./.github/actions/setup-python-env - name: Install the built distribution in a fresh virtual environment + env: + SETUPTOOLS_SCM_PRETEND_VERSION: "1.2.0" shell: bash run: | uv build --wheel @@ -113,6 +130,16 @@ jobs: --basetemp "$RUNNER_TEMP/powercontext-native-tests" tests/native/test_personal_service_lifecycle.py + - name: Exercise script installation and service reconciliation + env: + POWERCONTEXT_INSTALL_WHEEL: ${{ github.workspace }}/dist/powercontext-1.2.0-py3-none-any.whl + shell: bash + run: >- + .native-venv/bin/python -m pytest -q + --junitxml "$RUNNER_TEMP/installer-service.xml" + --basetemp "$RUNNER_TEMP/installer-service-tests" + tests/native/test_service_installation.py + - name: Report macOS lifecycle failures if: failure() run: >- @@ -129,7 +156,10 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: macos-native-service-logs - path: ${{ runner.temp }}/powercontext-native-tests/**/logs/* + path: | + ${{ runner.temp }}/powercontext-native-tests/**/logs/* + ${{ runner.temp }}/installer-service-tests/**/*.log + ${{ runner.temp }}/installer-service.xml if-no-files-found: ignore windows-task-scheduler: diff --git a/docs/en/docs/get-started/install-and-run.md b/docs/en/docs/get-started/install-and-run.md index 84d74c205d..d79a351e68 100644 --- a/docs/en/docs/get-started/install-and-run.md +++ b/docs/en/docs/get-started/install-and-run.md @@ -22,6 +22,151 @@ Windows CLI, Server, and personal-service support is experimental. Each Agent Ho requirements. Examples using Bash syntax require a Bash environment and cannot be pasted directly into PowerShell. Embedded seekDB is unavailable on Windows. +## Install with the recommended script + +The script installs the CLI and local Server in an isolated uv tool environment. It reuses compatible uv/Python, +or installs uv and Python 3.12 in user-owned directories. It does not need administrator privileges. + +macOS or Linux (Bash; `curl` or `wget` is required): + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash +``` + +Windows (PowerShell 5.1 or newer): + +```powershell +powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))" +``` + +Apply the PATH command printed at completion before running `powercontext` in your terminal. The installer does not +edit shell startup files or the persistent Windows PATH. `UV_INSTALL_DIR` selects where a missing uv is installed; +`UV_TOOL_BIN_DIR` selects the PowerContext executable directory. + +Installation runs unattended by default. Pass `--host codex` (repeatable) to install selected Agent integrations. +Only Agent setup needs Git and each host's prerequisites. `--no-hosts` remains an optional way to state the default; +it cannot be combined with `--host`. The [capability matrix](../integrations/capabilities.md) describes host support. + +For an existing remote Server, add `--profile client` to install only the CLI and Client dependencies. The default +`--profile local` includes the local Server. Neither profile starts a Server, registers a service, or overwrites +configuration or data. For a local installation, continue with `powercontext config init` and [Quick Start](quickstart.md). +Client-only installations use endpoint settings in the [remote connection guide](../operate/connect-remote-server.md). + +## Configure and install a personal service + +For personal macOS/Linux use, the recommended lifecycle is a native current-user service. The installer can +explicitly compose configuration and service registration: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \ + --configure --service --env-file "$HOME/.config/powercontext/powercontext.env" +``` + +`--configure` opens the existing wizard through the controlling terminal and requires `--env-file`. Save the file +to continue; cancellation stops service and host setup. Choose loopback binding for a personal service. Linux needs +an available `systemd --user` manager. In a non-interactive environment, provide an existing protected file instead: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \ + --service --env-file /path/to/powercontext.env +``` + +The installer validates that file, runs `service install`, checks `service status`, and runs `doctor --env-file` +against the Server settings in the same file. An explicit `--host` also receives this file and the installed release +ref. Client-only installations reject `--configure` and `--service` before installation. `--env-file` alone can supply +connection settings to an explicitly selected host without installing a local service. + +Windows supports the same explicit options experimentally; `--service` opts into login auto-start. Run `--configure` +in an interactive console. Protect an existing file using the ACL steps in [Deploy the Server](../operate/deploy-server.md). + +These options need an installed release providing `config init --require-write` and `doctor --env-file`. If the selected +release lacks them, the installer reports the failed stage and retains the Runtime; use a compatible release or the +separate configuration/service commands. Runtime installation, saved configuration, service registration, and Server +readiness are separate outcomes. `degraded` diagnostics return nonzero. A failed post-install stage does not roll back +packages, delete configuration, or remove an already committed service registration. + +After any file edit, including writing returned Scope IDs, reconcile using the original file: + +```bash +powercontext service install --env-file /path/to/powercontext.env +powercontext service status +powercontext doctor --env-file /path/to/powercontext.env +``` + +`doctor --env-file` gives the file authority over shell defaults. A Server file selects its listener; a client-only +file selects its Client URL. `--server-url` can explicitly override the diagnostic target. Generated next-step instructions +cover Scope creation and Agent acceptance; a running service does not establish an Agent workflow. + +## Installer version selection + +The default `--version latest` installs or upgrades to the newest stable release available from the selected index +and compatible with the chosen Python. It excludes prereleases. `--version` accepts an exact release, including an +explicit prerelease such as `1.3.0rc1`; it never substitutes a different version if that release is unavailable. + +For example, install PowerContext 1.4.0 with its Codex integration: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.4.0 --host codex +``` + +This selects package `1.4.0` and Git tag `powercontext-v1.4.0`. With `latest`, the installer reads the installed CLI's +version and uses its matching tag; it never uses the moving `master` branch for host setup. To add an integration later: + +```bash +powercontext setup codex --ref "powercontext-v$(powercontext --version)" +``` + +Use `powercontext setup --ref "powercontext-v$(powercontext --version)"` for an interactive host picker. + +In PowerShell, the same double-quoted expression works. An integration failure leaves the installed Runtime usable +and exits with an error and a retry instruction. Installation success does not establish Server readiness or host +workflow correctness; use the checks below and the integration's own guide. + +The installer verifies the installed version and advertised commands before reporting success. A package can install +successfully while command verification fails; in that case the old executable may already have been replaced. Review +the named command error and rerun with a known working `--version`. The installer does not roll package files back. + +## Retry dependency downloads with a mirror + +Package indexes, uv binaries, and Python distributions are separate downloads. Changing the package index does not +change the uv or Python download location. + +| Download | Explicit setting | Automatic China source | Global source | +| --- | --- | --- | --- | +| PowerContext and Python packages | `--index-url URL`, uv index environment variables or `uv.toml` | Tsinghua PyPI mirror | PyPI | +| uv installer | `POWERCONTEXT_UV_INSTALLER_URL` | USTC uv release mirror | Astral installer | +| uv binaries | `UV_DOWNLOAD_URL` or `UV_INSTALLER_GITHUB_BASE_URL` | USTC uv release mirror | Astral release channels | +| Python distributions | `UV_PYTHON_INSTALL_MIRROR` | NJU python-build-standalone mirror | uv default channels | + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --region cn +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --index-url https://pypi.org/simple +``` + +`--region auto|cn|global` overrides `POWERCONTEXT_INSTALL_REGION`. Auto selection uses a named local timezone, then the +locale territory, then global; it makes no geolocation request. An unavailable automatic mirror can fall back to the +official source. A reachable China package mirror remains selected even if it lacks the requested release; uv reports +resolution or artifact failures without retrying against another index. Use `--region global` or an explicit source +to select another index. Explicit sources never fall back automatically. +A mirror may lag PyPI: `latest` means the newest compatible stable release on the chosen index. + +Existing uv index settings and configuration files take precedence over automatic package mirrors. An explicit +`--index-url` overrides the default index only; additional uv indexes keep their priority. Private index credentials +belong in uv's authentication configuration, not in script arguments. uv does not read `PIP_INDEX_URL` or +`PIP_EXTRA_INDEX_URL`. Existing Python mirror settings and `uv.toml` also suppress automatic Python mirror selection. +`UV_ASTRAL_MIRROR_URL` is passed through for uv versions that support it. These settings are scoped to the installation; +your persistent package-manager configuration is unchanged. An existing uv is reused without upgrading it. + +To inspect the installer or pass several options in PowerShell, save it first: + +```powershell +irm https://powercontext.oceanbase.io/install.ps1 -OutFile install.ps1 +powershell -ExecutionPolicy Bypass -File .\install.ps1 --region cn --version 1.4.0 +``` + +Cached reinstallation with `UV_OFFLINE=1` can work when uv, compatible Python, and all required packages are already +present. This is not an offline distribution bundle; missing downloads fail explicitly. + ## Choose a version These instructions use PowerContext 1.4.0. Keep the package and Agent integration on diff --git a/docs/en/rfcs/1892-script-installation.md b/docs/en/rfcs/1892-script-installation.md new file mode 100644 index 0000000000..5dad92b3ae --- /dev/null +++ b/docs/en/rfcs/1892-script-installation.md @@ -0,0 +1,304 @@ +--- +title: Script Installation +description: Install the latest or an exact release with Bash and PowerShell, independent package and runtime mirrors, and explicit Agent selection. +--- + +- Proposal Name: `script_installation` +- Start Date: 2026-10-09 +- RFC PR: [oceanbase/powercontext#1892](https://github.com/oceanbase/powercontext/pull/1892) +- Related RFCs: [RFC 1733](1733-usability-and-agent-workflows.md), [RFC 1299](1299_local_server_availability_and_service_installation.md) + +# Summary + +Provide distribution-owned Bash and PowerShell entry points that install PowerContext through uv. The default +release is the latest stable version from the selected package index; `--version` selects an exact release. +The scripts provision missing uv and Python, install the selected Runtime profile, and optionally invoke the +existing Agent integration adapters with a tag matching the installed Runtime. + +Installation, configuration, diagnostics, and service operation have separate responsibilities. Installing software +does not configure inference providers, start a Server, or register a persistent service by default. Explicit +`--configure` and `--service` compose those existing CLI operations using one selected `--env-file`. The scripts and their +installation guide are the first recommended path in the READMEs and Quick Start. + +# Motivation + +A normal installation should not require users to coordinate Python discovery, uv installation, package extras, +package indexes, and integration Git refs. Package downloads, uv binaries, and Python distributions also use different +sources: changing the PyPI index alone cannot fix an inaccessible Python download. + +Installation places a versioned component and registers explicitly selected integrations. Configuration controls +mutable values such as Server URL, Scope, and capture policy. Diagnostics observe the resulting environment. +Keeping these responsibilities distinct lets installation improve independently of configuration storage, shared +Hooks, an operations tool, or service lifecycle changes. + +The intended outcome is one installation entry point per shell, reuse of existing dependencies, predictable version +selection, explicit host selection, recoverable partial failures, and verification through the installed product. + +# Guide-level explanation + +## Install a Runtime profile + +On macOS or Linux: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash +``` + +On Windows, using PowerShell 5.1 or newer: + +```powershell +powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))" +``` + +`local`, the default profile, installs CLI, Client, and local Server dependencies. `--profile client` installs CLI and +Client dependencies for an existing Server. Profile and host selection are independent: choosing a database role does +not select an Agent host, and selecting Codex does not change the Runtime profile. + +The installer reuses uv and compatible system or uv-managed Python. Otherwise it provisions uv and Python 3.12 in +user-owned locations. It prints the actual Runtime version, profile, executable directories, PATH command, and next +configuration command. It does not change persistent PATH settings. Apply the printed PATH command before continuing. + +## Select releases and integrations + +`--version latest` is the default. Repeating it checks for newer stable releases on the configured index. For a +repeatable package selection, use an exact version: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.2.0 --host codex +``` + +Installation runs unattended by default, without Git or a host picker. `--host` is repeatable and opts into integration +setup; selected hosts require Git and their own prerequisites. `--no-hosts` explicitly states the default and cannot +be combined with `--host`. For an interactive picker after installation, run +`powercontext setup --ref "powercontext-v$(powercontext --version)"`. + +The installed CLI supplies the exact version for `powercontext-vVERSION`. Host setup never resolves `latest` again +and never defaults to `master`. If a host installation fails, the Runtime remains installed and the script returns +nonzero with instructions to retry setup at that tag. Each adapter supplies its existing host-specific result. + +For personal macOS/Linux, configure a protected file, then run `powercontext service install --env-file .env`, +`powercontext service status`, and `powercontext doctor --env-file .env`. The native user manager owns the Server lifecycle. +Development, debugging, temporary use, and unavailable managers keep the foreground `server run` path. +Client-only installations follow the remote connection guide. Service registration remains explicit. + +## Explicit configuration and personal service setup + +The installer supports three composable options: + +| Option | Contract | +| --- | --- | +| `--configure` | Run `config init --require-write --output PATH` through a controlling terminal | +| `--service` | Validate the selected file, install the native user service, inspect status, and diagnose readiness | +| `--env-file PATH` | Select the same explicit configuration for configuration, service, diagnostics, and host setup | + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \ + --configure --service --env-file "$HOME/.config/powercontext/powercontext.env" +``` + +`--configure` and `--service` require the local profile and an explicit file. Unattended setup requires an existing +file and omits `--configure`. Invalid combinations, a missing existing file, and unavailable interactive input fail +before Runtime installation. The Bash wizard receives `/dev/tty` independently of the downloaded script's stdin; +PowerShell requires an interactive console. Ordinary installation remains unattended. + +The configuration CLI exposes `--require-write` without changing its normal cancellation behavior. With this option, +cancelled saving returns 130 and prevents service/host changes, including when a previous valid file remains present. +Both wrappers report saved configuration, validation, service verification, and any later failure separately. + +Service setup delegates protected-file loading, loopback restrictions, manager support, registration ownership, +reconciliation, and startup to the existing service layer. Windows remains experimental; the wrapper's explicit +`--service` also opts into login startup. A registered service is not sufficient for full completion: `service status` +must pass and `doctor --env-file` must report healthy readiness. Degraded readiness returns nonzero. + +`doctor --env-file` uses the strict Python environment loader and restores process settings afterwards. Server files +select the configured listener, independently of Client URLs used for SSH forwarding or stale caller defaults. +Client-only files select their Client URL; an explicit diagnostic `--server-url` takes priority. Shell does not source +the file or interpret its contents. Host setup receives `setup --env-file PATH HOST...` at the installed release tag. + +The installed release must provide the added CLI options; an older release reports the failed stage and leaves the +Runtime installed. Distribution publication must coordinate the package containing these options with the website. +After any file edit, including Scope IDs, or an upgrade, users rerun `service install --env-file` with the original file. +Generated configuration output and next-step instructions recommend this flow, including reconciliation after Scope +creation. A post-install failure retains packages, saved files, and committed registrations for explicit recovery. + +## Choose download sources + +`--region auto|cn|global` overrides `POWERCONTEXT_INSTALL_REGION`. Automatic selection checks local named timezone, +then locale territory, then uses global sources. It does not call a network location service. + +| Component | Global source | Automatic China source | Explicit control | +| --- | --- | --- | --- | +| PowerContext and dependencies | PyPI | Tsinghua PyPI mirror | `--index-url`, uv index settings and configuration | +| uv installer and binaries | Astral channels | USTC release mirror | `POWERCONTEXT_UV_INSTALLER_URL`, `UV_DOWNLOAD_URL`, uv installer mirror variables | +| Python | uv default channels | NJU python-build-standalone mirror | `UV_PYTHON_INSTALL_MIRROR`, uv Python download configuration | + +Existing uv configuration suppresses automatic package mirror selection. An explicit `--index-url` changes only the +default index; additional indexes keep uv's priority rules. uv owns authentication and index resolution. Script URL +arguments must use HTTPS and omit credentials; credentials belong in uv configuration. pip index environment variables +are not uv configuration. Python and uv download overrides are independent of package indexes. + +# Reference-level explanation + +## Installation responsibilities + +The entry points are `website/public/install.sh` and `website/public/install.ps1`, served as static website assets. +Their supported options and failure behavior are equivalent. They own bootstrap and sequencing; uv owns environment +creation and package resolution, while existing `powercontext setup` adapters own integration installation. + +The effective installation inputs are: + +```text +Package requirement + Runtime profile + explicit Host selection = installation operation +``` + +The package manager resolves the requirement before replacing the tool environment. Once installed, the CLI's exact +version is verified and used for all host setup. This keeps one release coordinate for the operation without adding a +second package resolver or introducing a release manifest dependency. + +The scripts preserve `powercontext setup`, `config`, `doctor`, and service commands. They do not move domain behavior +into shell, add a standalone Python installer engine, or change Runtime/public HTTP APIs. + +## Version and profile semantics + +- `latest` passes an unpinned profile requirement to `uv tool install --upgrade --reinstall-package powercontext --prerelease disallow`. It means the + newest stable version compatible with the selected interpreter and configured sources, not necessarily the version + most recently uploaded to another mirror. The PowerContext package is reinstalled so an existing prerelease cannot bypass stable selection. Resolution failures + are reported without substituting another requirement. +- An exact `X.Y.Z`, optionally followed by `aN`, `bN`, or `rcN`, uses `==VERSION`. Explicit prereleases are supported; + source refs, ranges, and URLs are not `--version` values. Releases before 0.1.0 are excluded. +- `local` uses `powercontext[cli,server]`; `client` uses `powercontext[cli]`, whose dependencies include the Client. + Rerunning with a different profile replaces that tool's dependency set. Independently added extras must be managed + through the documented manual installation path. +- The executable in `uv tool dir --bin` must report a release version. Exact requests must match it. All profiles + must expose CLI help and `capabilities --help`; `local` additionally requires + `config init --help` and `server run --help`. The script prints success only after these commands succeed. + These checks establish imports and command availability, not Server readiness or Agent workflow correctness. + +uv is bootstrapped at an installer-controlled version; that version is independent of the PowerContext release. +An existing uv is reused rather than upgraded silently. Python discovery excludes virtual environments so a project +venv cannot become an accidental installation prerequisite. If no compatible Python 3.11+ is found, uv provisions +Python 3.12. uv remains responsible for platform, architecture, and wheel compatibility errors. + +## Mirror precedence and recovery + +User-specified uv/Python sources are preserved without fallback. For automatic China selection, an unavailable uv +mirror installer falls back to Astral; failed mirrored uv artifacts may use official sources. Python's exact build +URL is obtained from uv, and its mirrored artifact is checked before installation. If the automatic Python mirror +fails, installation retries with uv's default channels. + +Automatic China package selection makes a bounded availability request to the PowerContext index page. An +unreachable mirror falls back to PyPI before tool installation. A reachable mirror remains selected even if it lacks +the requested release; uv reports that resolution failure. Global and explicit sources go directly to uv. The scripts +never interpret index HTML, wheel filenames, encoded URLs, package compatibility, or authentication. They do not +switch indexes after a tool installation attempt, which may already have changed installed files. + +Shell environment changes are local to the installer process; PowerShell restores temporary source variables in +`finally`. Existing configuration files are neither parsed by shell nor rewritten. Only installer-owned temporary +downloads are cleaned. `UV_INSTALL_DIR`, `UV_TOOL_DIR`, and `UV_TOOL_BIN_DIR` retain their location controls. + +## Persistence and compatibility + +Ordinary package installation does not read or rewrite `.env`, credentials, data directories, or database schemas, +and does not stop or restart a service. Explicit `--configure` asks the CLI to save only the selected file; explicit +`--service` asks the native layer to reconcile its owned registration and restart when its existing contract requires it. Users follow the existing upgrade and migration instructions when starting an updated +Server. An exact-version retry may reuse cached packages; `latest` intentionally allows upgrades. + +`UV_OFFLINE=1` permits cached reinstallation only when uv, a compatible Python, and all dependencies are present. +It disables installer source probes as well as uv networking. It does not promise a complete offline distribution. +Prerequisites remain available after later failures. A resolution failure for an unavailable exact version preserves +the existing tool. Once uv accepts a package, verification can fail after replacing the old executable: report +"package installed, verification failed" and allow an explicit version retry, without claiming rollback. A later host +setup failure preserves the verified Runtime. There is no cross-component rollback or repair of unrelated host state. + +Windows retains its experimental product status. Native acceptance runs on Linux, macOS, and Windows; host-specific +support still comes from each integration's capability contract. + +## Executable contract and acceptance + +`tests/fixtures/installation/*.json` defines shared public inputs and expected outcomes for both shell adapters. +`tests/test_installation_contract.py` executes those cases with real uv and small offline fixture wheels: host opt-in, +profiles, exact-version verification, unavailable commands, and partial host failure. Cases observe installed +capabilities, selected hosts, release refs and exit results. They do not fix internal function boundaries or call order. +The same catalog runs on each native operating system; fixture host/service execution is not native service or Agent +acceptance. POSIX controlling-terminal cases cover a piped configuration request and cancellation. Native service CI +adds installed-script acceptance for persistent memory, environment-file reconciliation, and a stopped-service upgrade. + +`tests/native/test_installation.py` runs the actual shell installer, uv, installed PowerContext CLI, and HTTP Server. +A wheel built from the tested commit uses release-shaped metadata and a direct local file constraint. That constraint +selects the tested artifact; it does not establish an independently enforced checksum. Qualification includes: + +- missing uv/Python under global and China source selection, existing tools/configuration, and spaces/Unicode paths; +- real stable/prerelease wheel resolution through a controlled package index, default upgrade, exact selection, and + preserving the installed version when a requested release is unavailable; +- Client-only installation and profile changes, with no local Server state created by installation; +- `.env` generation and validation, readiness, Memory remember/search, cached offline reinstall, and restart readback; +- explicit index failure, conflicting host choices, unattended and piped Bash installation, and the downloaded + PowerShell scriptblock entry point. + +A separate CI matrix executes the suite on all three operating systems. Release reference checks preserve script +`latest` defaults while updating explicit version examples. Website validation confirms documentation links and the +static build. Syntax checks alone do not establish Windows or macOS installation support, and these tests do not +claim that a real Agent host completes its capture/recall workflow. + +[Reproducible first-principles and ablation studies](https://github.com/PsiACE/powercontext/tree/feat/installation-contract/experiments/installation) pin upstream +sources, installer baselines, uv versions, controlled inputs and observed results. They support these decisions: +removing implicit host selection preserves explicit host failure semantics; a filename-based preflight rejects valid +encoded package URLs; prerelease-only indexes require explicit stable policy; and an accepted package can still have +an unusable entry point. They remain separate from maintained product acceptance and carry their own execution limits. + +# Drawbacks + +Bash and PowerShell duplicate some sequencing and source-selection policy. Automatic mirrors add operational +availability dependencies. `latest` deliberately changes over time and is unsuitable for reproducible deployment +without an exact version. Matching tags coordinate releases but do not independently prove host compatibility. + +# Rationale and alternatives + +A uv-only command is retained for users who already manage Python and uv; it cannot bootstrap a clean machine. +Source installation is useful for development but is not the default release channel. Pinning the public script to +a PowerContext version would leave new users on stale releases until the website is republished. + +A Python engine still needs a bootstrap on machines without Python. Generating the shell adapters would still require +native behavioral tests and would add generator/versioning machinery. Shared conformance cases provide executable +parity without another runtime asset. Introduce an engine or generator only when a concrete policy needs it. + +Immutable manifests and a second installation ledger are useful when distributing independently versioned raw +artifacts. Here uv owns wheel resolution, tool environments and its records. The installer observes the actual launcher +and delegates repair to an explicit uv-backed retry. This boundary avoids two authorities for the same installation. + +# Prior art + +[RFC 1408](https://github.com/oceanbase/powercontext/pull/1408) defines the separation of installation, configuration, +and diagnostics; independent Runtime profiles and host selection; component-level recovery; and explicit service +registration. [RFC 1299](1299_local_server_availability_and_service_installation.md) defines personal service lifecycle. + +[Magpie's installer](https://github.com/yetone/magpie/blob/023f5aaad2ecd41ae04390166b9cac9a0b300d81/site/public/install.sh) +separates an authoritative release feed from artifact mirrors and verifies a binary checksum before replacement. +Its raw binary ownership justifies that verification; its manifest is not a replacement for Python package standards. + +[Lody's daemon installation contract](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/specs/daemon-upgrade-installation.md) +resolves and executes the actual npm installation destination and checks replacement readiness. Its separate +[managed Agent runtime](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/apps/cli/src/agent/managed-agent-runtime.ts) +owns raw archives and therefore owns manifests and completion records. Apply the actual-executable check here; +retain service readiness in the service boundary. The public tag-release workflow does not publish installers. + +[Bub](https://github.com/bubbuild/bub/tree/b4a61bf1326729a024161d22ba20019b8500f907/website/public) bootstraps uv before +running its Python preset resolver. Its terminal and macOS Bash fixes motivate explicit host setup and native tests. +[uv tools](https://docs.astral.sh/uv/concepts/tools/) and +[configuration](https://docs.astral.sh/uv/concepts/configuration-files/) define the reused environment and source +semantics; the [Python Simple API](https://packaging.python.org/en/latest/specifications/simple-repository-api/) +defines package index interpretation. A shell regex cannot implement those contracts. + +# Unresolved questions + +No further cross-subsystem decision is required for this contract. Complete offline bundles, cryptographic installer +manifests, independently packaged host artifacts, and changes to configuration storage need separate designs. +The platform matrix must remain green before promotion; Windows product support remains experimental. + +# Future possibilities + +The scripts can bootstrap a distribution-owned installer engine if installation plans later need immutable component +manifests, independent integration versions, or durable per-component repair records. An offline bundle can include +all required artifacts explicitly. Neither extension should make Server startup or service registration implicit. + +The personal-service onboarding work tracks [Issue #1900](https://github.com/oceanbase/powercontext/issues/1900). diff --git a/docs/zh/docs/get-started/install-and-run.md b/docs/zh/docs/get-started/install-and-run.md index bd5380fba4..75f797ab7a 100644 --- a/docs/zh/docs/get-started/install-and-run.md +++ b/docs/zh/docs/get-started/install-and-run.md @@ -20,6 +20,134 @@ description: 安装 PowerContext 1.4.0,并运行本地 Server。 Windows 的 CLI、Server 和个人服务支持为试验性;各 Agent Host 仍需满足自身的平台要求。 使用 Bash 语法的示例需要 Bash 环境,不可直接粘贴到 PowerShell。嵌入式 seekDB 不支持 Windows。 +## 使用推荐安装脚本 + +脚本在独立的 uv tool 环境中安装 CLI 和本地 Server。已有兼容的 uv/Python 时会直接复用;缺少时在用户目录安装 +uv 和 Python 3.12,无需管理员权限。 + +macOS 或 Linux(使用 Bash,需要 `curl` 或 `wget`): + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash +``` + +Windows(PowerShell 5.1 或更新版本): + +```powershell +powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))" +``` + +先执行安装完成时打印的 PATH 命令,再在终端运行 `powercontext`。安装器不会修改 shell 启动文件或 Windows 的持久 +PATH。`UV_INSTALL_DIR` 指定缺少 uv 时的安装目录,`UV_TOOL_BIN_DIR` 指定 PowerContext 可执行文件目录。 + +默认安装可以无人值守执行。使用 `--host codex` 安装选定的 Agent 集成;多个宿主可重复传入 `--host`。 +只有集成安装需要 Git 及对应宿主的前置条件。`--no-hosts` 可以显式表达默认行为,不能与 `--host` 同时使用。 +宿主支持范围见[能力矩阵](../integrations/capabilities.md)。 + +连接已有远程 Server 时,加上 `--profile client`,只安装 CLI 和 Client 依赖。默认 `--profile local` 包含本地 Server。 +两种模式都不会启动 Server、注册服务或覆盖配置与数据。本地安装使用 `powercontext config init`,继续阅读 +[快速开始](quickstart.md);Client-only 安装按[远程连接指南](../operate/connect-remote-server.md)设置地址和认证。 + +## 配置并安装个人服务 + +个人 macOS/Linux 推荐使用原生当前用户服务。安装器可以显式串联配置向导和服务注册: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \ + --configure --service --env-file "$HOME/.config/powercontext/powercontext.env" +``` + +`--configure` 通过控制终端打开现有向导,需要显式 `--env-file`。保存后才能继续;取消会中止后续服务和宿主安装。 +个人服务选择 loopback 绑定;Linux 需要可用的 `systemd --user`。无人值守环境使用已有受保护文件: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \ + --service --env-file /path/to/powercontext.env +``` + +安装器验证配置,执行 `service install`、`service status`,再通过 `doctor --env-file` 检查同一文件中的 Server。 +显式 `--host` 也会使用该文件及实际安装版本对应的 ref。Client-only 模式在安装前拒绝 `--configure` 和 `--service`; +单独使用 `--env-file` 可以为显式宿主安装提供连接配置,而不安装本地服务。 + +Windows 的相同选项仍为试验性支持;`--service` 显式启用登录自启动。`--configure` 需要交互控制台。 +已有文件的 ACL 保护方式见[部署 Server](../operate/deploy-server.md)。 + +这些选项要求所选发行版提供 `config init --require-write` 和 `doctor --env-file`。缺少时,安装器报告失败阶段并保留 +Runtime;使用兼容发行版,或分开执行配置与服务命令。Runtime 安装、配置保存、服务注册和 Server 就绪分别报告。 +`degraded` 诊断返回非零状态;后续失败不会回滚包文件、删除配置或移除已提交的服务注册。 + +任何文件修改,包括写入返回的 Scope ID 后,都要使用原文件重新注册: + +```bash +powercontext service install --env-file /path/to/powercontext.env +powercontext service status +powercontext doctor --env-file /path/to/powercontext.env +``` + +`doctor --env-file` 以文件为准,覆盖调用者 shell 的默认配置。Server 文件选择其监听地址,Client-only 文件选择 Client URL; +显式 `--server-url` 可以覆盖诊断目标。生成的后续步骤仍负责引导 Scope 创建和 Agent 验收;服务运行不代表 Agent 工作流已完成。 + +## 选择脚本安装版本 + +默认 `--version latest` 安装或升级到所选包源中与当前 Python 兼容的最新稳定版,排除预发布版本。 +`--version` 也支持指定准确版本,包括 `1.3.0rc1` 这样的显式预发布版本;源中没有指定版本时会报错,不会换成其他版本。 + +例如,安装 PowerContext 1.4.0 及其 Codex 集成: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.4.0 --host codex +``` + +Python 包版本为 `1.4.0`,对应 Git tag 为 `powercontext-v1.4.0`。使用 `latest` 时,安装器读取实际安装的 CLI 版本, +并使用对应的 tag 安装集成,不会使用会移动的 `master` 分支。稍后添加集成时可以执行: + +```bash +powercontext setup codex --ref "powercontext-v$(powercontext --version)" +``` + +需要交互选择宿主时,运行 `powercontext setup --ref "powercontext-v$(powercontext --version)"`。 + +PowerShell 也支持这里的双引号表达式。集成安装失败时,已安装的 Runtime 会保留,脚本返回错误并给出重试提示。 +安装成功不代表 Server 就绪或宿主工作流可用;仍需执行下文检查及集成文档中的验证。 + +安装器在报告成功前验证版本和所选模式的命令。包安装成功后仍可能无法通过命令验证,此时旧可执行文件可能已被替换。 +请查看错误中指出的命令,再指定已知可用的 `--version` 重试。安装器不回滚包文件。 + +## 使用镜像重试依赖下载 + +包索引、uv 二进制和 Python 发行版是三类独立下载。更换包索引不会改变 uv 或 Python 的下载位置。 + +| 下载内容 | 显式配置 | 中国区域自动源 | 全球默认源 | +| --- | --- | --- | --- | +| PowerContext 和 Python 包 | `--index-url URL`、uv 索引环境变量或 `uv.toml` | 清华 PyPI 镜像 | PyPI | +| uv 安装器 | `POWERCONTEXT_UV_INSTALLER_URL` | USTC uv release 镜像 | Astral 安装器 | +| uv 二进制 | `UV_DOWNLOAD_URL` 或 `UV_INSTALLER_GITHUB_BASE_URL` | USTC uv release 镜像 | Astral release 渠道 | +| Python 发行版 | `UV_PYTHON_INSTALL_MIRROR` | NJU python-build-standalone 镜像 | uv 默认渠道 | + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --region cn +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --index-url https://pypi.org/simple +``` + +`--region auto|cn|global` 优先于 `POWERCONTEXT_INSTALL_REGION`。自动选择依次参考本地命名时区、locale 地区,最后采用 +全球源,不请求网络定位服务。自动镜像不可用时可回退官方源;中国区域包镜像可达时,即使缺少请求版本,也保持选中。 +解析或文件下载失败由 uv 报告,不会再换源重试安装。需要换源时使用 `--region global` 或显式指定来源。 +显式配置的源不会自动回退。镜像可能有同步延迟:`latest` 指所选源中的最新兼容稳定版。 + +已有的 uv 索引环境变量和配置文件优先于自动包镜像。`--index-url` 只覆盖默认索引,额外 uv 索引仍保留其优先级。 +私有源凭据应使用 uv 认证配置,不放在脚本参数里。uv 不读取 `PIP_INDEX_URL` 或 `PIP_EXTRA_INDEX_URL`。 +已有 Python 镜像设置和 `uv.toml` 同样会禁用自动 Python 镜像选择。`UV_ASTRAL_MIRROR_URL` 会透传给支持它的 uv 版本。 +这些设置只作用于本次安装,不改写持久包管理配置;已有 uv 会直接复用,不会自动升级。 + +需要检查脚本内容,或在 PowerShell 传入多个选项时,可以先保存脚本: + +```powershell +irm https://powercontext.oceanbase.io/install.ps1 -OutFile install.ps1 +powershell -ExecutionPolicy Bypass -File .\install.ps1 --region cn --version 1.4.0 +``` + +已有 uv、兼容 Python 和完整依赖缓存时,可以使用 `UV_OFFLINE=1` 重装。这不等于离线发行包;缺少下载内容时会明确报错。 + ## 选择版本 本页使用 PowerContext 1.4.0。包与 Agent 集成保持版本一致: diff --git a/docs/zh/rfcs/1892-script-installation.md b/docs/zh/rfcs/1892-script-installation.md new file mode 100644 index 0000000000..45717fa5ca --- /dev/null +++ b/docs/zh/rfcs/1892-script-installation.md @@ -0,0 +1,263 @@ +--- +title: 脚本安装 +description: 通过 Bash 和 PowerShell 安装最新或指定版本,独立管理包与运行时镜像,并显式选择 Agent。 +--- + +- Proposal Name: `script_installation` +- Start Date: 2026-10-09 +- RFC PR: [oceanbase/powercontext#1892](https://github.com/oceanbase/powercontext/pull/1892) +- Related RFCs: [RFC 1733](1733-usability-and-agent-workflows.md)、[RFC 1299](1299_local_server_availability_and_service_installation.md) + +# Summary + +提供由发行版维护的 Bash 和 PowerShell 入口,通过 uv 安装 PowerContext。默认版本是所选包索引中的最新稳定版, +`--version` 可以指定准确版本。脚本补齐缺少的 uv 和 Python,安装所选 Runtime profile,并按需调用现有 Agent 集成 +适配器,使用与实际安装的 Runtime 对应的 tag。 + +安装、配置、诊断和服务运行各自承担独立职责。默认安装软件不会配置推理服务、启动 Server 或注册持久服务。显式 `--configure` 和 `--service` 可以通过 +现有 CLI 串联这些操作,并使用同一份 `--env-file`。 +README 和快速开始将脚本及其安装指南作为首选路径。 + +# Motivation + +正常安装不应要求用户自行协调 Python 发现、uv 安装、包 extras、包索引及集成 Git ref。Python 包、uv 二进制和 +Python 发行版也来自不同渠道:只更换 PyPI 索引无法解决 Python 下载不可达的问题。 + +安装负责放置版本化组件并注册显式选择的集成;配置管理 Server URL、Scope、采集策略等可变值;诊断观察安装结果。 +分清这些职责,安装体验就可以独立于配置存储、统一 Hooks、运维工具或服务生命周期改进。 + +预期结果是每种 shell 一个入口、复用已有依赖、明确的版本选择、显式的宿主选择、可恢复的部分失败, +以及通过实际安装产物进行验证。 + +# Guide-level explanation + +## 安装 Runtime profile + +macOS 或 Linux: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash +``` + +Windows,使用 PowerShell 5.1 或更新版本: + +```powershell +powershell -ExecutionPolicy Bypass -c "& ([scriptblock]::Create((irm https://powercontext.oceanbase.io/install.ps1)))" +``` + +默认 `local` profile 安装 CLI、Client 和本地 Server 依赖。`--profile client` 只安装连接已有 Server 所需的 CLI 和 +Client 依赖。Profile 与宿主选择相互独立:选择数据库角色不隐含选择 Agent,选择 Codex 也不会更改 Runtime profile。 + +安装器复用已有 uv,以及兼容的系统 Python 或 uv 管理的 Python;否则在用户目录补齐 uv 和 Python 3.12。 +完成后打印实际 Runtime 版本、profile、可执行文件目录、PATH 命令及下一步配置命令,不修改持久 PATH 设置。 +继续操作前,先执行打印的 PATH 命令。 + +## 选择发行版和集成 + +默认值为 `--version latest`,重复执行会检查已配置索引上的更新稳定版。需要可重复的包版本选择时,指定准确版本: + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- --version 1.2.0 --host codex +``` + +默认安装无需 Git 或宿主选择界面,可以无人值守执行。`--host` 可以重复,用于显式安装集成;这时需要 Git 和 +各宿主自己的前置条件。`--no-hosts` 显式表达默认行为,不能与 `--host` 同时使用。安装后需要交互选择宿主时,运行 +`powercontext setup --ref "powercontext-v$(powercontext --version)"`。 + +已安装的 CLI 提供准确版本,形成 `powercontext-vVERSION`。宿主安装不再次解析 `latest`,也不默认使用 `master`。 +宿主安装失败时,保留已安装的 Runtime,脚本返回非零状态,并提示按该 tag 重试 setup;各适配器提供其已有的宿主结果。 + +个人 macOS/Linux 配置受保护文件后,运行 `powercontext service install --env-file .env`、`powercontext service status` +和 `powercontext doctor --env-file .env`,由原生用户服务管理器负责生命周期。开发、调试、临时使用或缺少 manager 时, +保留前台 `server run`。Client-only 模式按远程连接指南设置地址和认证。持久服务仍由用户显式注册。 + +## 显式配置与个人服务安装 + +安装器提供三个可以组合的选项: + +| 选项 | 契约 | +| --- | --- | +| `--configure` | 通过控制终端运行 `config init --require-write --output PATH` | +| `--service` | 验证所选文件,安装原生用户服务,检查状态与就绪 | +| `--env-file PATH` | 为配置、服务、诊断和宿主安装选择同一份显式文件 | + +```bash +curl -fsSL https://powercontext.oceanbase.io/install.sh | bash -s -- \ + --configure --service --env-file "$HOME/.config/powercontext/powercontext.env" +``` + +`--configure` 和 `--service` 要求 local profile 及显式文件。无人值守安装省略 `--configure`,提供已有文件。 +无效组合、缺少已有文件或交互终端时,在 Runtime 安装前失败。Bash 将 `/dev/tty` 单独交给向导,避免与下载脚本的 +stdin 混用;PowerShell 要求交互控制台。普通安装继续支持无人值守。 + +配置 CLI 增加 `--require-write`,保留独立使用时的取消行为。指定该选项时,取消保存返回 130,即使已有有效旧文件, +也不会继续修改服务或宿主。两个入口分别报告配置保存、验证、服务验证以及后续失败。 + +服务安装复用现有服务层的文件保护、loopback、manager 支持、注册归属、reconcile 和启动规则。 +Windows 仍为试验性;入口的显式 `--service` 同时同意登录自启动。完成注册后,还必须通过 `service status` 和 +`doctor --env-file` 的就绪诊断;`degraded` 返回非零状态。 + +`doctor --env-file` 复用严格的 Python 环境加载器,结束后恢复进程设置。Server 文件选择其监听地址,不受 SSH 转发 +所用 Client URL 或调用者旧配置影响;Client-only 文件选择 Client URL,显式诊断 `--server-url` 优先。 +Shell 不执行文件内容。宿主安装使用 `setup --env-file PATH HOST...`,并绑定实际安装版本对应的 tag。 + +所选发行版必须提供新增 CLI 选项;旧版缺少时,报告失败阶段并保留 Runtime。发布需要协调包含这些选项的包与网站。 +任何文件修改(包括 Scope ID)或升级后,使用原文件重新执行 `service install --env-file`。 +配置输出和后续步骤统一推荐这一路径,并在 Scope 创建后提示重新注册。后续失败保留包、已保存文件和已提交的服务注册, +供用户显式恢复。 + +## 选择下载来源 + +`--region auto|cn|global` 优先于 `POWERCONTEXT_INSTALL_REGION`。自动模式依次参考本地命名时区、locale 地区, +最后采用全球源,不调用网络定位服务。 + +| 组件 | 全球默认源 | 中国区域自动源 | 显式控制 | +| --- | --- | --- | --- | +| PowerContext 及依赖 | PyPI | 清华 PyPI 镜像 | `--index-url`、uv 索引设置及配置文件 | +| uv 安装器及二进制 | Astral 渠道 | USTC release 镜像 | `POWERCONTEXT_UV_INSTALLER_URL`、`UV_DOWNLOAD_URL`、uv 安装器镜像变量 | +| Python | uv 默认渠道 | NJU python-build-standalone 镜像 | `UV_PYTHON_INSTALL_MIRROR`、uv Python 下载配置 | + +已有 uv 配置时不自动选择包镜像。显式 `--index-url` 只更改默认索引,额外索引沿用 uv 的优先级。 +认证和索引解析由 uv 负责。脚本 URL 参数必须使用 HTTPS,且不包含凭据;凭据通过 uv 配置管理。 +pip 的索引环境变量不属于 uv 配置。Python 和 uv 下载覆盖项与包索引彼此独立。 + +# Reference-level explanation + +## 安装职责 + +入口为 `website/public/install.sh` 和 `website/public/install.ps1`,作为网站静态资源发布。 +两者支持相同的选项和失败语义。脚本负责引导与编排;uv 负责环境创建和包解析;现有 `powercontext setup` 适配器 +负责集成安装。 + +安装操作由以下输入确定: + +```text +包 requirement + Runtime profile + 显式宿主选择 = 安装操作 +``` + +包管理器在替换工具环境前解析 requirement。安装完成后验证 CLI 的准确版本,再用于全部宿主安装。 +这样一次操作具有统一的发行版坐标,无需新增包解析器,也不依赖另一个发行清单系统。 + +保留 `powercontext setup`、`config`、`doctor` 和服务命令。不把领域行为搬到 shell,不引入独立 Python 安装引擎, +也不修改 Runtime 或公共 HTTP API。 + +## 版本和 profile 语义 + +- `latest` 向 `uv tool install --upgrade --reinstall-package powercontext --prerelease disallow` 传入未固定版本的 profile requirement。 + 它表示所选解释器及已配置源兼容的最新稳定版,不一定是另一镜像上最近上传的版本。重新安装 PowerContext 包,避免已有预发布版本绕过稳定版选择。解析失败直接报告,不改换 requirement。 +- 准确版本使用 `==VERSION`,格式为 `X.Y.Z`,可追加 `aN`、`bN` 或 `rcN`。支持显式预发布版本; + `--version` 不接受源码 ref、版本范围或 URL,也不支持 0.1.0 之前的版本。 +- `local` 使用 `powercontext[cli,server]`;`client` 使用 `powercontext[cli]`,后者已经包含 Client 依赖。 + 使用不同 profile 重跑会替换该工具的依赖集合;自行添加的 extras 应通过文档中的手动安装路径维护。 +- `uv tool dir --bin` 中的可执行文件必须报告发行版本;显式请求必须与其一致。所有 profile 都需要通过 CLI help、 + `capabilities --help`;`local` 还需要通过 `config init --help` 和 `server run --help`。 + 全部通过后才打印安装成功。这些检查验证导入和命令可用性,不代表 Server 就绪或 Agent 工作流可用。 + +uv 使用安装器维护的固定引导版本,与 PowerContext 发行版本无关。已有 uv 直接复用,不悄悄升级。 +Python 发现排除虚拟环境,避免项目 venv 意外成为安装前提。没有兼容的 Python 3.11+ 时,通过 uv 安装 Python 3.12。 +平台、架构和 wheel 兼容性错误由 uv 报告。 + +## 镜像优先级与恢复 + +保留用户显式指定的 uv/Python 来源,不做自动回退。中国区域自动选择时,uv 镜像安装器不可用则回退 Astral; +uv 镜像文件下载失败时可以尝试官方源。Python 的准确构建 URL 由 uv 提供,安装前检查对应镜像文件; +自动 Python 镜像安装失败后,使用 uv 默认渠道重试。 + +中国区域自动包源选择对 PowerContext 索引页发起有超时限制的可用性请求;镜像不可达时,在工具安装前回退 PyPI。 +可达镜像即使缺少请求版本也保持选中,由 uv 报告解析失败。全球源和显式来源直接交给 uv。脚本不解释索引 HTML、 +wheel 文件名、编码 URL、包兼容性或认证,也不在一次可能已经改变安装文件的工具安装尝试后换源重试。 + +Shell 环境变化局限于安装器进程;PowerShell 在 `finally` 恢复临时来源变量。不用 shell 解析或改写已有配置文件, +只清理由安装器创建的临时下载目录。保留 `UV_INSTALL_DIR`、`UV_TOOL_DIR` 和 `UV_TOOL_BIN_DIR` 的位置控制。 + +## 持久化与兼容性 + +普通包安装不读取或改写 `.env`、凭据、数据目录或数据库 schema,也不停止或重启服务。显式 `--configure` +由 CLI 保存所选文件;显式 `--service` 由原生层 reconcile 自有注册,并按既有契约在需要时重启。 +启动升级后的 Server 时,用户按现有升级和迁移指南操作。准确版本重试可以使用缓存;`latest` 则明确允许升级。 + +`UV_OFFLINE=1` 只支持已有 uv、兼容 Python 和全部依赖时的缓存重装,同时禁用安装器源探测和 uv 网络访问, +不承诺完整离线发行包。后续步骤失败会保留已安装的前置依赖。准确版本不存在导致的解析失败会保留原工具; +但 uv 接受新包后,验证失败可能发生在旧可执行文件已被替换之后。此时明确报告“包已安装,验证失败”,允许通过准确 +版本重试,不声称已经回滚。后续宿主安装失败则保留已验证的 Runtime。不提供跨组件回滚,也不修复无关宿主状态。 + +Windows 保持产品的试验性支持状态。原生验收在 Linux、macOS 和 Windows 执行;具体宿主支持范围仍由各集成的能力契约决定。 + +## 可执行契约与验收 + +`tests/fixtures/installation/*.json` 定义两个 shell 适配器共享的公开输入与预期结果。 +`tests/test_installation_contract.py` 使用真实 uv 和小型离线 fixture wheel 执行宿主显式选择、profile、准确版本 +校验、命令缺失和宿主部分失败用例。用例观察安装后的能力、所选宿主、发行 ref 和退出结果,不固定内部函数边界或 +调用顺序。同一组用例在各操作系统原生执行;模拟宿主或服务执行不等于真实原生服务或 Agent 验收。POSIX 控制终端用例覆盖管道安装中的配置请求与取消; +原生服务 CI 通过安装后的脚本验证记忆持久化、文件修改后的重新注册和停止服务后的升级。 + +`tests/native/test_installation.py` 执行真实 shell 安装器、uv、安装后的 PowerContext CLI 和 HTTP Server。 +从被测提交构建 wheel,使用发行版形式的元数据和直接本地文件约束。该约束选定被测产物,不构成独立执行的校验和验证。 +验收包括: + +- 全球源及中国区域源下缺少 uv/Python 的安装、已有工具与配置、包含空格和中文的路径; +- 通过受控包索引解析真实稳定版和预发布版 wheel、默认升级、指定版本,以及版本不存在时保留原安装; +- Client-only 安装及 profile 切换,安装过程不创建本地 Server 状态; +- `.env` 生成与验证、就绪检查、Memory 保存与搜索、离线缓存重装及重启后的回读; +- 显式索引失败、宿主选项冲突、无人值守与 Bash 管道安装,以及 PowerShell 下载脚本后执行 scriptblock 的入口。 + +独立 CI 矩阵在三个操作系统执行上述套件。发布引用检查保持脚本的 `latest` 默认值,同时更新显式版本示例。 +网站验证检查文档链接和静态构建。语法检查本身不证明 Windows 或 macOS 安装可用;这些测试也不声称 +真实 Agent 宿主已完成采集与召回工作流。 + +[可复现的第一性原理和消融研究](https://github.com/PsiACE/powercontext/tree/feat/installation-contract/experiments/installation) +固定上游源码、安装器基线、uv 版本、受控输入和 +观测结果。它们支持以下决策:删除隐式宿主选择仍可保留显式宿主失败语义;基于文件名的预检查会拒绝合法编码包 URL; +只有预发布版本的索引需要显式稳定版策略;包安装成功后入口仍可能不可用。研究与持续维护的产品验收分开,分别说明 +实际执行范围和限制。 + +# Drawbacks + +Bash 和 PowerShell 会重复部分编排和来源选择策略。自动镜像增加外部可用性依赖。 +`latest` 会随时间变化,可重复部署需要指定版本。匹配 tag 能协调发行版,但不能单独证明宿主兼容性。 + +# Rationale and alternatives + +保留直接运行 uv 命令的路径,供已有 Python 和 uv 的用户使用;它无法引导干净机器。 +源码安装适合开发,不作为默认发行渠道。把公共脚本固定在某个 PowerContext 版本,会使新用户在网站重新发布前 +一直安装过期版本。 + +没有 Python 的机器仍需要为 Python 引擎提供引导。生成 shell 适配器仍需原生行为测试,还会增加生成器和版本管理。 +共享契约用例可以提供可执行的一致性约束,无需分发新的运行时资源。仅当具体策略确有需要时再引入引擎或生成器。 + +分发独立版本的原始产物时,不可变清单和额外安装账本有其用途。这里由 uv 管理 wheel 解析、工具环境及自身记录; +安装器观察实际入口,通过显式的 uv 安装重试修复组件,避免同一安装状态出现两个权威来源。 + +# Prior art + +[RFC 1408](https://github.com/oceanbase/powercontext/pull/1408) 定义了安装、配置和诊断的职责分离, +独立的 Runtime profile 与宿主选择、组件级恢复和显式服务注册。 +[RFC 1299](1299_local_server_availability_and_service_installation.md) 定义个人服务生命周期。 + +[Magpie 安装器](https://github.com/yetone/magpie/blob/023f5aaad2ecd41ae04390166b9cac9a0b300d81/site/public/install.sh) +将权威发行源与产物镜像分开,在替换二进制前检查校验和。它直接拥有原始二进制的分发职责,因此需要这层验证; +其清单不能替代 Python 包标准。 + +[Lody daemon 安装契约](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/specs/daemon-upgrade-installation.md) +解析并执行实际 npm 安装位置,检查替换后的就绪状态。它另有直接管理原始压缩包的 +[Agent runtime](https://github.com/LodyAI/Lody/blob/811b573329716b23e1144e5d66211ea4ddfb0dfd/apps/cli/src/agent/managed-agent-runtime.ts), +因此拥有清单和完成记录。本方案采用实际可执行文件检查,服务就绪仍归服务边界负责。其公开 tag-release 工作流不发布安装器。 + +[Bub](https://github.com/bubbuild/bub/tree/b4a61bf1326729a024161d22ba20019b8500f907/website/public) 先引导 uv,再运行 +Python preset 解析器。它对终端和 macOS Bash 的修复支持显式宿主安装与原生测试的必要性。 +[uv 工具环境](https://docs.astral.sh/uv/concepts/tools/)及 +[配置规则](https://docs.astral.sh/uv/concepts/configuration-files/)定义复用的环境与来源语义; +[Python Simple API](https://packaging.python.org/en/latest/specifications/simple-repository-api/)定义包索引解释规则。 +Shell 正则无法实现这些契约。 + +# Unresolved questions + +接受本契约无需额外的跨子系统决策。完整离线包、带密码学校验的安装清单、独立打包的宿主产物以及配置存储变化, +需要各自设计。推广前必须保持平台矩阵通过,Windows 产品支持仍为试验性。 + +# Future possibilities + +当安装计划需要不可变组件清单、独立集成版本或持久组件修复记录时,脚本可以引导发行版维护的独立安装引擎。 +离线包也可以显式携带全部所需产物。这些扩展继续保持 Server 启动与服务注册的显式同意。 + +个人服务引导工作关联 [Issue #1900](https://github.com/oceanbase/powercontext/issues/1900)。 diff --git a/scripts/release_version.py b/scripts/release_version.py index 57bf563e31..fc505dcddf 100644 --- a/scripts/release_version.py +++ b/scripts/release_version.py @@ -45,12 +45,14 @@ *INSTALL_REFERENCES, r"PowerContext ", r"package `", + r"--version ", r"^To upgrade to ", ), "docs/zh/docs/get-started/install-and-run.md": ( *INSTALL_REFERENCES, r"PowerContext ", r"Python 包版本为 `", + r"--version ", r"^升级到 ", ), "integrations/dsh/plugins/powercontext/README.md": INSTALL_REFERENCES, diff --git a/src/powercontext/cli/config.py b/src/powercontext/cli/config.py index f18386607b..75e93aba16 100644 --- a/src/powercontext/cli/config.py +++ b/src/powercontext/cli/config.py @@ -28,7 +28,6 @@ from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum -from importlib.metadata import version from pathlib import Path from typing import TYPE_CHECKING, Annotated, Never from urllib.parse import urlsplit @@ -139,35 +138,15 @@ class ApiProtocol: _PROTOCOL_BY_ID = {protocol.identifier: protocol for protocol in API_PROTOCOLS} -AGENTS: dict[str, tuple[str, str, str]] = { - "codex": ("Codex", "powercontext setup codex --source oceanbase/powercontext --ref master", "codex"), - "claude-code": ( - "Claude Code", - "powercontext setup claude-code --source oceanbase/powercontext --ref master", - "claude", - ), - "dsh": ("DeepSeek Harness", "powercontext setup dsh --source oceanbase/powercontext --ref master", "dsh web"), - "opencode": ( - "OpenCode", - "powercontext setup opencode --source oceanbase/powercontext --ref master", - "opencode", - ), - "pi": ("Pi", "powercontext setup pi --source oceanbase/powercontext --ref master", "pi"), - "openclaw": ( - "OpenClaw", - "powercontext setup openclaw --source oceanbase/powercontext --ref master", - "openclaw", - ), - "hermes": ( - "Hermes", - "powercontext setup hermes --source oceanbase/powercontext --ref master", - "hermes", - ), - "workbuddy": ( - "WorkBuddy", - "powercontext setup workbuddy --source oceanbase/powercontext --ref master", - "重启 WorkBuddy", - ), +AGENTS: dict[str, tuple[str, str]] = { + "codex": ("Codex", "codex"), + "claude-code": ("Claude Code", "claude"), + "dsh": ("DeepSeek Harness", "dsh web"), + "opencode": ("OpenCode", "opencode"), + "pi": ("Pi", "pi"), + "openclaw": ("OpenClaw", "openclaw"), + "hermes": ("Hermes", "hermes"), + "workbuddy": ("WorkBuddy", "重启 WorkBuddy"), } # Input hints only, not a provider allowlist. Unknown prefixes can attach arbitrary variables. @@ -358,13 +337,18 @@ def init_command( template: Annotated[ bool, typer.Option("--template", help="Use the basic model-free template instead of the guided setup.") ] = False, + require_write: Annotated[ + bool, typer.Option(help="Exit with status 130 if configuration is cancelled without saving.") + ] = False, ) -> None: """Create a working configuration through a short guided setup.""" if not template: from powercontext.cli.config_wizard import run_wizard - run_wizard(output, language=None if language is None else language.value, advanced=advanced) + written = run_wizard(output, language=None if language is None else language.value, advanced=advanced) + if require_write and not written: + raise typer.Exit(130) return if output.exists() and not force: @@ -377,6 +361,8 @@ def init_command( content = update_environment_document(existing, configuration) if not _confirm_environment_write(output, existing=existing, updated=content): typer.echo("No changes written.") + if require_write: + raise typer.Exit(130) return backup = write_environment(output, content, backup=output.exists()) except (ConfigError, EnvironmentFileError, OSError, UnicodeError, ValidationError) as error: @@ -1095,7 +1081,11 @@ def _removes_inference_configuration(existing: str, updated: str) -> bool: def _print_next_steps(path: Path) -> None: + from powercontext.cli.config_startup import server_startup_commands + from powercontext.cli.config_wizard_installation import installation_source + quoted = shlex.quote(str(path.resolve())) + source = installation_source(Path(__file__)) typer.secho("\nEnvironment file", bold=True, fg=typer.colors.CYAN) typer.echo(f" Path {path.resolve()} (mode 0600)") typer.echo(" Authentication disabled by default.") @@ -1103,17 +1093,22 @@ def _print_next_steps(path: Path) -> None: " If Bearer authentication is enabled, read POWERCONTEXT_SERVER_AUTH_TOKEN from this file;" " the value is never printed." ) - typer.echo(f"\nStart Server:\n powercontext server run --env-file {quoted}") + typer.echo("\nStart Server:") + for command in server_startup_commands( + path.resolve(), + host=parse_environment(path.read_text(encoding="utf-8")).get("POWERCONTEXT_SERVER_HTTP_HOST", "127.0.0.1"), + ): + typer.echo(f" {command}") write_inference_capability_notice(generation_model=None, embedding_model=None) typer.secho("\nSupported Coding Agents (choose one):", bold=True, fg=typer.colors.CYAN) - for host, (name, setup, launch) in AGENTS.items(): - if host == "dsh": - installed = version("powercontext") - setup = ( - f"powercontext setup dsh --source oceanbase/powercontext --ref powercontext-v{installed}" - if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?", installed) - else "powercontext setup dsh --source /path/to/matching-powercontext-checkout" - ) + for host, (name, launch) in AGENTS.items(): + setup = f"powercontext setup --env-file {quoted} {host}" + if source is None: + setup += " --help" + else: + setup += f" --source {shlex.quote(source.source)}" + if source.ref: + setup += f" --ref {shlex.quote(source.ref)}" typer.echo(f"\n{name}:\n {setup}") if launch.startswith("重启"): typer.echo(f" {launch}") diff --git a/src/powercontext/cli/config_startup.py b/src/powercontext/cli/config_startup.py new file mode 100644 index 0000000000..f596a8739c --- /dev/null +++ b/src/powercontext/cli/config_startup.py @@ -0,0 +1,39 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Startup guidance shared by configuration output and saved instructions.""" + +import shlex +import sys +from pathlib import Path + +from powercontext.transport import is_loopback_host + + +def personal_service_recommended(host: str) -> bool: + return sys.platform in {"darwin", "linux"} and is_loopback_host(host) + + +def server_startup_commands(path: Path, *, host: str = "127.0.0.1") -> list[str]: + quoted = shlex.quote(str(path)) + commands = [f"powercontext config validate --env-file {quoted}"] + if personal_service_recommended(host): + commands.extend(( + f"powercontext service install --env-file {quoted}", + "powercontext service status", + f"powercontext doctor --env-file {quoted}", + )) + else: + commands.append(f"powercontext server run --env-file {quoted}") + return commands diff --git a/src/powercontext/cli/config_wizard.py b/src/powercontext/cli/config_wizard.py index 3222300cb5..79bd442ea7 100644 --- a/src/powercontext/cli/config_wizard.py +++ b/src/powercontext/cli/config_wizard.py @@ -111,7 +111,7 @@ def note(self, en: str, zh: str) -> None: self.ui.say(en, zh) -def run_wizard(output: Path, *, language: str | None = None, advanced: bool = False) -> None: +def run_wizard(output: Path, *, language: str | None = None, advanced: bool = False) -> bool: """Collect answers and write files only after explicit final confirmation.""" from powercontext.cli.config import ConfigError @@ -162,7 +162,7 @@ def run_wizard(output: Path, *, language: str | None = None, advanced: bool = Fa _agents(state) elif mode == "edit": _edit_modules(state) - _finish(state, output, content) + return _finish(state, output, content) except (ConfigError, EnvironmentFileError, OSError, ValueError) as error: if isinstance(error, (WizardInputError, EnvironmentFileError, OSError)): message = str(error) @@ -1103,7 +1103,7 @@ def _validate_files(state: Wizard, values: dict[str, str]) -> None: raise WizardInputError(message) from None -def _finish(state: Wizard, output: Path, original_content: str) -> None: +def _finish(state: Wizard, output: Path, original_content: str) -> bool: ui = state.ui ui.section("8. Review files", "8. 检查配置文件") @@ -1153,10 +1153,11 @@ def _finish(state: Wizard, output: Path, original_content: str) -> None: typer.echo(f" {path}") if not ui.confirm("Save these files?", "保存这些文件?"): ui.say("No changes written.", "未写入任何文件。") - return + return False _save_bundle(state, bundle, snapshots) _finish_seekdb_install(state) _show_connection_details(state, output, values, notes_file) + return True def _save_bundle(state: Wizard, bundle: dict[Path, str], snapshots: dict[Path, str]) -> None: @@ -1215,8 +1216,10 @@ def _show_connection_details(state: Wizard, output: Path, values: dict[str, str] ui = state.ui ui.section("Connection details", "连接与下一步") if not state.client_only: - command = f"powercontext server run --env-file {shlex.quote(str(output))}" - ui.say(f"Start Server: {command}", f"启动 Server:{command}") + from powercontext.cli.config_startup import server_startup_commands + + for command in server_startup_commands(output, host=values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")): + ui.say(f"Start Server: {command}", f"启动 Server:{command}") if state.ssh_tunnel_command: ui.say( f"Run on the client computer: {state.ssh_tunnel_command}", @@ -1257,8 +1260,50 @@ def _connection_base_url(state: Wizard, values: dict[str, str]) -> str: return f"http://{host}:{port}" +def _server_start_steps(state: Wizard, output: Path) -> list[str]: + from powercontext.cli.config_startup import personal_service_recommended, server_startup_commands + + if state.client_only: + return [] + ui = state.ui + personal_service = personal_service_recommended(state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")) + lines: list[str] = [] + lines += [ + "```bash", + *server_startup_commands(output, host=state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")), + "```", + "", + ] + if personal_service: + lines += [ + ui.text( + "The native user-service manager owns the Server lifecycle. After upgrades or any edit to " + "this environment file, rerun these commands with the same file. Use `powercontext server run` " + "for development or temporary foreground use after stopping the personal service.", + "原生用户服务管理器负责 Server 生命周期。升级或修改这份环境文件后,使用同一文件重新执行上述命令。" + "开发或临时前台运行可先停止个人服务,再使用 `powercontext server run`。", + ), + "", + ] + if state.values.get(f"{SERVER}AUTH_TOKEN"): + lines += [ + ui.text( + f"The Server token is stored as `POWERCONTEXT_SERVER_AUTH_TOKEN` in `{output}`. " + "Do not paste it into logs or issues.", + f"Server Token 保存在 `{output}` 的 `POWERCONTEXT_SERVER_AUTH_TOKEN` 中;请勿将其粘贴到日志或 Issue。", + ), + "", + ] + return lines + + def _next_steps(state: Wizard, output: Path, client_file: Path) -> str: + from powercontext.cli.config_startup import personal_service_recommended, server_startup_commands + ui = state.ui + personal_service = not state.client_only and personal_service_recommended( + state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1") + ) lines = [ "# " + ui.text("PowerContext next steps", "PowerContext 后续步骤"), "", @@ -1268,24 +1313,7 @@ def _next_steps(state: Wizard, output: Path, client_file: Path) -> str: ), "", ] - if not state.client_only: - lines += [ - "```bash", - f"powercontext config validate --env-file {shlex.quote(str(output))}", - f"powercontext server run --env-file {shlex.quote(str(output))}", - "```", - "", - ] - if state.values.get(f"{SERVER}AUTH_TOKEN"): - lines += [ - ui.text( - f"The Server token is stored as `POWERCONTEXT_SERVER_AUTH_TOKEN` in `{output}`. " - "Do not paste it into logs or issues.", - f"Server Token 保存在 `{output}` 的 `POWERCONTEXT_SERVER_AUTH_TOKEN` 中;" - "请勿将其粘贴到日志或 Issue。", - ), - "", - ] + lines += _server_start_steps(state, output) if state.agents or state.client_only: if state.scenario != "local": lines += [ @@ -1328,6 +1356,18 @@ def _next_steps(state: Wizard, output: Path, client_file: Path) -> str: lines += _alternate_client_check_steps(state) if state.planned_scopes: lines += _scope_creation_steps(state) + if personal_service: + lines += [ + ui.text( + "After saving the returned Scope IDs, reconcile the service's environment-file identity:", + "保存返回的 Scope ID 后,重新注册服务以更新环境文件身份:", + ), + "", + "```bash", + *server_startup_commands(output, host=state.values.get(f"{SERVER}HTTP_HOST", "127.0.0.1")), + "```", + "", + ] lines += [ ui.text( "After saving the returned Scope IDs in the environment file, reload it in each Agent's " @@ -1344,7 +1384,7 @@ def _next_steps(state: Wizard, output: Path, client_file: Path) -> str: "```", "", ] - lines += _agent_installation_steps(state) + lines += _agent_installation_steps(state, env_file=client_file) if "profile" in state.features: lines += _profile_policy_steps(state) lines += ["## " + ui.text("Checks and pending steps", "验收与待完成项"), ""] @@ -1498,7 +1538,7 @@ def _profile_policy_script(address: str, scope_name: str, authorization_name: st """).splitlines() -def _agent_installation_steps(state: Wizard) -> list[str]: +def _agent_installation_steps(state: Wizard, *, env_file: Path | None = None) -> list[str]: if not state.agents: return [] source = installation_source(Path(__file__)) @@ -1522,7 +1562,10 @@ def _agent_installation_steps(state: Wizard) -> list[str]: if source is None: lines.append(f"powercontext setup {agent} --help") continue - command = f"powercontext setup {agent} --source {shlex.quote(source.source)}" + command = "powercontext setup" + if env_file is not None: + command += f" --env-file {shlex.quote(str(env_file))}" + command += f" {agent} --source {shlex.quote(source.source)}" if source.ref: command += f" --ref {shlex.quote(source.ref)}" if spec.setup_server_url: diff --git a/src/powercontext/cli/config_wizard_installation.py b/src/powercontext/cli/config_wizard_installation.py index 3bd832fe5b..5a9602a84e 100644 --- a/src/powercontext/cli/config_wizard_installation.py +++ b/src/powercontext/cli/config_wizard_installation.py @@ -27,17 +27,18 @@ @dataclass(frozen=True) class InstallationSource: - """A verified checkout or the Git source and requested ref of an installation.""" + """A verified checkout, preserved Git origin, or tagged package-index release.""" source: str ref: str | None = None def installation_source(module_path: Path) -> InstallationSource | None: - """Return a usable plugin source without treating site-packages as a checkout. + """Return plugin coordinates without treating site-packages as a checkout. Wheels exclude the integration marketplaces. PEP 610 metadata preserves the - origin of Git installs, including forks and requested branches or tags. + origin of Git installs, including forks and requested branches or tags. Package + index releases use the distribution's version at the official release tag. """ checkout = module_path.resolve().parents[3] if ( @@ -49,7 +50,16 @@ def installation_source(module_path: Path) -> InstallationSource | None: return InstallationSource(str(checkout)) try: - direct_url_text = importlib.metadata.distribution("powercontext").read_text("direct_url.json") + distribution = importlib.metadata.distribution("powercontext") + direct_url_text = distribution.read_text("direct_url.json") + if direct_url_text is None: + metadata = distribution.metadata + # PackageMetadata's protocol does not expose get(). + installed_version = metadata["Version"] if "Version" in metadata else None # noqa: SIM401 + if isinstance(installed_version, str) and re.fullmatch( + r"[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?", installed_version + ): + return InstallationSource("oceanbase/powercontext", f"powercontext-v{installed_version}") direct_url = json.loads(direct_url_text) if direct_url_text else None except (importlib.metadata.PackageNotFoundError, OSError, UnicodeError, ValueError): return None diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 970d5fd072..7a225c1acb 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -40,6 +40,7 @@ from pydantic import ValidationError from powercontext.cli.dsh_runtime import DshProfile, resolve_dsh_target +from powercontext.cli.env_file import EnvironmentFileError, environment_context, read_environment_file from powercontext.cli.hosts import stdin_is_tty from powercontext.cli.installation import SetupError, setup from powercontext.cli.transport import is_remote_http @@ -266,8 +267,20 @@ def doctor( session_id: Annotated[str | None, typer.Option(help="Exact Kimi session.")] = None, runtime_data_dir: Annotated[Path | None, typer.Option(help="Actual ZCode plugin data directory.")] = None, prepare: Annotated[bool, typer.Option(help="Probe ZCode readonly context preparation.")] = False, + env_file: Annotated[ + Path | None, typer.Option(help="Diagnose the Server defined in this file, independently of shell defaults.") + ] = None, ) -> None: """Check selected integrations, or saved integrations and the environment by default.""" + if env_file is not None: + if agents: + message = "Run doctor without an Agent selection when using --env-file." + raise typer.BadParameter(message, param_hint="--env-file") + _doctor_environment_file( + env_file, server_url=server_url, allow_insecure_http=allow_insecure_http, json_output=json_output + ) + return + from powercontext.cli.hosts import ( diagnostic_result, doctor_checks, @@ -352,6 +365,50 @@ def _codex_endpoint_updates(marketplace: str, plugin_version: str, server_url: s ) from error +def _doctor_environment_file( + path: Path, *, server_url: str | None, allow_insecure_http: bool | None, json_output: bool +) -> None: + try: + checks = run_environment_diagnostics(path, server_url=server_url, allow_insecure_http=allow_insecure_http) + except (EnvironmentFileError, OSError, ValueError) as error: + typer.echo("Cannot diagnose the environment file; check its path, assignments, and Server settings.", err=True) + raise typer.Exit(2) from error + _write_diagnostics(checks, json_output=json_output) + if not _diagnostics_ok(checks): + raise typer.Exit(code=1) + + +def run_environment_diagnostics( + path: Path, *, server_url: str | None = None, allow_insecure_http: bool | None = None +) -> dict[str, Diagnostic]: + """Observe the same Server configuration used by a personal service.""" + + loaded = read_environment_file(path.expanduser()) + cleared = { + name + for name in os.environ + if name.casefold().startswith(("powercontext_client_", "powercontext_server_")) or name == "POWERCONTEXT_HOME" + } + with environment_context(loaded, override=True, clear=cleared): + if server_url is None and any(name.casefold().startswith("powercontext_server_") for name in loaded): + from powercontext.server.configuration import server_settings_context + + with server_settings_context(environment=loaded) as settings: + host = settings.http.host + if host == "0.0.0.0": # noqa: S104 - diagnose wildcard binds via loopback + host = "127.0.0.1" + elif host in {"::", "[::]"}: + host = "::1" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + server_url = f"http://{host}:{settings.http.port}" + else: + server_url = server_url or next( + (value for name, value in loaded.items() if name.casefold() == "powercontext_client_server_url"), None + ) + return run_diagnostics(server_url=server_url, allow_insecure_http=allow_insecure_http) + + def run_diagnostics(*, server_url: str | None = None, allow_insecure_http: bool | None = None) -> dict[str, Diagnostic]: """Collect installed-environment diagnostics without changing state.""" diff --git a/src/powercontext/service/controller.py b/src/powercontext/service/controller.py index dabcce1280..47976b19d9 100644 --- a/src/powercontext/service/controller.py +++ b/src/powercontext/service/controller.py @@ -677,6 +677,14 @@ def _build_definition(self, env_file: Path | None, *, start_on_login: bool) -> S raise ServiceError( # noqa: TRY003 f"invalid personal service configuration: {error}", exit_code=2 ) from error + if loaded_env is not None and ( + any(name.casefold().startswith("powercontext_client_") for name in loaded_env.values) + and not any(name.casefold().startswith("powercontext_server_") for name in loaded_env.values) + ): + raise ServiceError( # noqa: TRY003 + "the selected environment file contains only Client settings; configure a local Server before installing a service", + exit_code=2, + ) if loaded_env is None: inherited = sorted( name diff --git a/tests/fixtures/installation/README.md b/tests/fixtures/installation/README.md new file mode 100644 index 0000000000..5ada199593 --- /dev/null +++ b/tests/fixtures/installation/README.md @@ -0,0 +1,48 @@ +# Installer behavior contract + +The JSON catalogs describe public installer inputs and observable outcomes. Both native shell entry points use +the same cases through `tests/test_installation_contract.py`. The catalogs are test inputs, not bootstrap downloads +or an additional installation manifest. RFC 1892 defines the overall installation and recovery boundary. + +Run the offline contract with an existing Python and uv: + +```bash +uv run pytest tests/test_installation_contract.py -q +``` + +Each file has `schema_version: 1` and a `cases` array. Case IDs must be unique across catalogs. + +| Field | Meaning | +| --- | --- | +| `id` | Stable scenario name shown in test results | +| `args` | Arguments passed to the native installer | +| `git` | Whether Git is discoverable; defaults to false | +| `setup_exit` | Exit status of the fixture host operation; defaults to success | +| `reported_version` | Fixture CLI version; defaults to the wheel's `1.2.0` | +| `environment_file` | Create a protected existing configuration file for the case | +| `terminal` | Run a piped Bash installer through a POSIX controlling terminal | +| `config_exit`, `validate_exit`, `service_exit`, `doctor_exit` | Exit status of the fixture operation | +| `missing_commands` | Public command help paths omitted by the fixture | +| `expected.exit_code` | Installer completion or failure | +| `expected.runtime_installed` | Whether uv installed the tool launcher, including verification failures | +| `expected.server_extra` | Whether the selected profile contains the fixture Server dependency | +| `expected.selected_hosts` | Hosts actually submitted to setup; defaults to none | +| `expected.setup_ref` | Release tag actually submitted to setup | +| `expected.configuration_saved` | Whether interactive configuration wrote its selected file | +| `expected.service`, `expected.doctor` | Whether registration and same-file diagnostics produced their fixture results | +| `expected.setup_env_file` | Whether host setup received the selected configuration file | +| `expected.verification_failed` | Whether success must remain unannounced | +| `expected.error_contains` | Required diagnostic fragment | + +The harness builds small standards-shaped wheels, installs them through real uv in private directories without +network access, and executes the resulting native launcher. Profile cases inspect an optional installed dependency. +Host cases observe the setup request and result through the fixture CLI. They do not start a real Agent, execute Git, +or establish Server readiness. No case requires a particular shell function name, private call order or subprocess count. + +The installation CI matrix runs these cases on Linux, macOS and Windows. The same workflow also builds the actual +PowerContext wheel and runs `tests/native/test_installation.py` for bootstrap, configuration, HTTP readiness, +Memory persistence, profile replacement and version selection. `tests/native/test_install_sources.py` adds Linux +loopback index fault tests using the real downloader and uv, with only distribution endpoints redirected to fixtures. +Those source tests do not qualify public mirror availability or native Windows source handling. Native service CI +also runs `tests/native/test_service_installation.py` on disposable Linux/macOS runners with the built wheel, a real +user-service manager, configuration reconciliation, an offline stopped-service upgrade, and persistent Memory readback. diff --git a/tests/fixtures/installation/capabilities.json b/tests/fixtures/installation/capabilities.json new file mode 100644 index 0000000000..9c9fe5dca2 --- /dev/null +++ b/tests/fixtures/installation/capabilities.json @@ -0,0 +1,116 @@ +{ + "schema_version": 1, + "cases": [ + { + "id": "missing-root-help", + "args": [ + "--no-hosts" + ], + "missing_commands": [ + "" + ], + "expected": { + "exit_code": 1, + "runtime_installed": true, + "server_extra": true, + "verification_failed": true, + "error_contains": "Package installed, but" + } + }, + { + "id": "missing-capabilities", + "args": [ + "--no-hosts" + ], + "missing_commands": [ + "capabilities" + ], + "expected": { + "exit_code": 1, + "runtime_installed": true, + "server_extra": true, + "verification_failed": true, + "error_contains": "Package installed, but" + } + }, + { + "id": "runtime-without-optional-setup", + "args": [ + "--no-hosts" + ], + "missing_commands": [ + "setup" + ], + "expected": { + "exit_code": 0, + "runtime_installed": true, + "server_extra": true + } + }, + { + "id": "missing-config-init", + "args": [ + "--no-hosts" + ], + "missing_commands": [ + "config init" + ], + "expected": { + "exit_code": 1, + "runtime_installed": true, + "server_extra": true, + "verification_failed": true, + "error_contains": "Package installed, but" + } + }, + { + "id": "missing-server-run", + "args": [ + "--no-hosts" + ], + "missing_commands": [ + "server run" + ], + "expected": { + "exit_code": 1, + "runtime_installed": true, + "server_extra": true, + "verification_failed": true, + "error_contains": "Package installed, but" + } + }, + { + "id": "client-does-not-require-local-commands", + "args": [ + "--profile", + "client", + "--no-hosts" + ], + "missing_commands": [ + "config init", + "server run" + ], + "expected": { + "exit_code": 0, + "runtime_installed": true, + "server_extra": false + } + }, + { + "id": "exact-version-report-mismatch", + "args": [ + "--version", + "1.2.0", + "--no-hosts" + ], + "reported_version": "9.9.9", + "expected": { + "exit_code": 1, + "runtime_installed": true, + "server_extra": true, + "verification_failed": true, + "error_contains": "differs from the requested release" + } + } + ] +} diff --git a/tests/fixtures/installation/hosts.json b/tests/fixtures/installation/hosts.json new file mode 100644 index 0000000000..cfd3007aa5 --- /dev/null +++ b/tests/fixtures/installation/hosts.json @@ -0,0 +1,111 @@ +{ + "schema_version": 1, + "cases": [ + { + "id": "unattended-default", + "args": [], + "git": false, + "expected": { + "exit_code": 0, + "runtime_installed": true, + "selected_hosts": [], + "server_extra": true + } + }, + { + "id": "compatibility-no-hosts", + "args": [ + "--no-hosts" + ], + "git": false, + "expected": { + "exit_code": 0, + "runtime_installed": true, + "selected_hosts": [], + "server_extra": true + } + }, + { + "id": "client-profile", + "args": [ + "--profile", + "client" + ], + "git": false, + "expected": { + "exit_code": 0, + "runtime_installed": true, + "selected_hosts": [], + "server_extra": false + } + }, + { + "id": "explicit-hosts", + "args": [ + "--host", + "codex", + "--host", + "claude" + ], + "git": true, + "expected": { + "exit_code": 0, + "runtime_installed": true, + "selected_hosts": [ + "codex", + "claude" + ], + "setup_ref": "powercontext-v1.2.0", + "server_extra": true + } + }, + { + "id": "host-partial-failure", + "args": [ + "--host", + "codex" + ], + "git": true, + "setup_exit": 17, + "expected": { + "exit_code": 1, + "runtime_installed": true, + "selected_hosts": [ + "codex" + ], + "setup_ref": "powercontext-v1.2.0", + "server_extra": true, + "error_contains": "Runtime installed, but integration setup did not complete" + } + }, + { + "id": "host-choice-conflict", + "args": [ + "--host", + "codex", + "--no-hosts" + ], + "git": true, + "expected": { + "exit_code": 1, + "runtime_installed": false, + "selected_hosts": [], + "error_contains": "--host and --no-hosts cannot be combined" + } + }, + { + "id": "explicit-host-needs-git", + "args": [ + "--host", + "codex" + ], + "git": false, + "expected": { + "exit_code": 1, + "runtime_installed": false, + "selected_hosts": [], + "error_contains": "requires Git" + } + } + ] +} diff --git a/tests/fixtures/installation/services.json b/tests/fixtures/installation/services.json new file mode 100644 index 0000000000..8ed88773ba --- /dev/null +++ b/tests/fixtures/installation/services.json @@ -0,0 +1,200 @@ +{ + "schema_version": 1, + "cases": [ + { + "id": "service-existing-file", + "environment_file": true, + "args": [ + "--service", + "--env-file", + "{env_file}" + ], + "expected": { + "exit_code": 0, + "runtime_installed": true, + "service": true, + "doctor": true + } + }, + { + "id": "service-registration-failure", + "environment_file": true, + "args": [ + "--service", + "--env-file", + "{env_file}" + ], + "service_exit": 1, + "expected": { + "exit_code": 1, + "runtime_installed": true, + "service": true, + "error_contains": "personal service setup did not complete" + } + }, + { + "id": "service-readiness-failure", + "environment_file": true, + "args": [ + "--service", + "--env-file", + "{env_file}" + ], + "doctor_exit": 1, + "expected": { + "exit_code": 1, + "runtime_installed": true, + "service": true, + "doctor": true, + "error_contains": "Server diagnostics did not pass" + } + }, + { + "id": "invalid-service-configuration", + "environment_file": true, + "args": [ + "--service", + "--env-file", + "{env_file}" + ], + "validate_exit": 2, + "expected": { + "exit_code": 1, + "runtime_installed": true, + "error_contains": "configuration validation failed" + } + }, + { + "id": "service-and-host-use-same-file", + "environment_file": true, + "git": true, + "args": [ + "--service", + "--env-file", + "{env_file}", + "--host", + "codex" + ], + "expected": { + "exit_code": 0, + "runtime_installed": true, + "service": true, + "doctor": true, + "selected_hosts": [ + "codex" + ], + "setup_ref": "powercontext-v1.2.0", + "setup_env_file": true + } + }, + { + "id": "client-rejects-local-service", + "environment_file": true, + "args": [ + "--profile", + "client", + "--service", + "--env-file", + "{env_file}" + ], + "expected": { + "exit_code": 1, + "runtime_installed": false, + "error_contains": "require --profile local" + } + }, + { + "id": "service-requires-explicit-file", + "args": [ + "--service" + ], + "expected": { + "exit_code": 1, + "runtime_installed": false, + "error_contains": "require --env-file" + } + }, + { + "id": "service-requires-existing-file", + "args": [ + "--service", + "--env-file", + "{env_file}" + ], + "expected": { + "exit_code": 1, + "runtime_installed": false, + "error_contains": "existing file" + } + }, + { + "id": "configuration-requires-terminal", + "args": [ + "--configure", + "--service", + "--env-file", + "{env_file}" + ], + "expected": { + "exit_code": 1, + "runtime_installed": false, + "error_contains": "requires a terminal" + } + }, + { + "id": "piped-configuration-and-service", + "terminal": true, + "args": [ + "--configure", + "--service", + "--env-file", + "{env_file}" + ], + "expected": { + "exit_code": 0, + "runtime_installed": true, + "configuration_saved": true, + "service": true, + "doctor": true + } + }, + { + "id": "cancelled-configuration-stops-service", + "terminal": true, + "config_exit": 130, + "args": [ + "--configure", + "--service", + "--env-file", + "{env_file}" + ], + "expected": { + "exit_code": 1, + "runtime_installed": true, + "configuration_saved": false, + "error_contains": "configuration did not complete" + } + }, + { + "id": "existing-configuration-cancellation-stops-hosts", + "terminal": true, + "environment_file": true, + "git": true, + "config_exit": 130, + "args": [ + "--configure", + "--service", + "--env-file", + "{env_file}", + "--host", + "codex" + ], + "expected": { + "exit_code": 1, + "runtime_installed": true, + "selected_hosts": [], + "error_contains": "configuration did not complete" + } + } + ] +} diff --git a/tests/native/test_install_sources.py b/tests/native/test_install_sources.py new file mode 100644 index 0000000000..041c4c0832 --- /dev/null +++ b/tests/native/test_install_sources.py @@ -0,0 +1,228 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Linux installer source acceptance with real uv and a loopback package index.""" + +from __future__ import annotations + +import hashlib +import http.server +import io +import os +import shutil +import subprocess +import sys +import urllib.parse +import zipfile +from pathlib import Path +from threading import Thread +from typing import ClassVar + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BASH = shutil.which("bash") +UV = os.environ.get("POWERCONTEXT_INSTALL_UV") or shutil.which("uv") +pytestmark = pytest.mark.skipif(sys.platform != "linux" or not UV, reason="requires Linux and real uv") + + +def build_wheel(version: str) -> bytes: + data = io.BytesIO() + metadata = f"powercontext-{version}.dist-info" + with zipfile.ZipFile(data, "w") as archive: + archive.writestr( + "fixture_cli.py", + "from importlib.metadata import version\ndef main():\n print(version('powercontext'))\n", + ) + archive.writestr( + f"{metadata}/METADATA", + f"Metadata-Version: 2.1\nName: powercontext\nVersion: {version}\nProvides-Extra: cli\nProvides-Extra: server\n", + ) + archive.writestr(f"{metadata}/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n") + archive.writestr(f"{metadata}/entry_points.txt", "[console_scripts]\npowercontext = fixture_cli:main\n") + archive.writestr(f"{metadata}/RECORD", "") + return data.getvalue() + + +@pytest.fixture +def installation(tmp_path): # noqa: C901 - one fixture owns the server and subprocess environment + assert BASH + bash_command: str = BASH + wheels = {version: build_wheel(version) for version in ("1.0.0", "1.1.0", "1.2.0rc1")} + + class Index(http.server.BaseHTTPRequestHandler): + mirror_status = 200 + mirror_versions: ClassVar[list[str]] = ["1.0.0", "1.1.0", "1.2.0rc1"] + requests: ClassVar[list[str]] = [] + + # Keep this controlled fixture compatible with standalone Python 3.11. + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + pass + + def do_HEAD(self): + self.do_GET() + + def do_GET(self): + self.requests.append(self.path) + path = urllib.parse.unquote(self.path) + if path.startswith("/cn/") and self.mirror_status != 200: + self.send_error(self.mirror_status) + return + if path.endswith("/powercontext/"): + versions = self.mirror_versions if path.startswith("/cn/") else list(wheels) + source = path.split("/")[1] + links = [] + for version in versions: + name = f"powercontext-{version}-py3-none-any.whl" + # Valid URL encoding and opaque anchor text reproduce the filename-probe defect. + href = f"/{source}/files/{name}".replace(".", "%2E") + digest = hashlib.sha256(wheels[version]).hexdigest() + links.append(f'download') + payload = ("" + "\n".join(links)).encode() + content_type = "text/html" + elif path.endswith(".whl"): + payload = wheels[path.rsplit("/", 1)[-1].split("-")[1]] + content_type = "application/octet-stream" + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(payload) + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Index) + worker = Thread(target=server.serve_forever, daemon=True) + worker.start() + base = f"http://127.0.0.1:{server.server_port}" + utilities = tmp_path / "utilities" + utilities.mkdir() + # Translate only distribution endpoints to fixtures; run the real downloader and uv. + mappings = { + "https://pypi.tuna.tsinghua.edu.cn/simple": base + "/cn/simple", + "https://pypi.org/simple": base + "/global/simple", + "https://explicit.invalid/simple": base + "/cn/simple", + } + for name, executable in (("uv", UV), ("curl", shutil.which("curl"))): + assert executable + wrapper = utilities / name + wrapper.write_text( + f"#!{sys.executable}\nimport os, sys\nmapping={mappings!r}\n" + "args=[mapping.get(value, value) for value in sys.argv[1:]]\n" + "args=[next((target + value[len(source):] for source,target in mapping.items() " + "if value.startswith(source)), value) for value in args]\n" + "for key in ('UV_DEFAULT_INDEX','UV_INDEX','UV_INDEX_URL'):\n" + " if key in os.environ: os.environ[key]=mapping.get(os.environ[key],os.environ[key])\n" + f"os.execv({executable!r}, [{executable!r}, *args])\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + env = { + "PATH": str(utilities) + os.pathsep + os.environ["PATH"], + "HOME": str(tmp_path / "home"), + "TMPDIR": str(tmp_path), + "LANG": "C.UTF-8", + "XDG_CONFIG_HOME": str(tmp_path / "config"), + "XDG_CONFIG_DIRS": str(tmp_path / "system-config"), + "UV_TOOL_DIR": str(tmp_path / "tools"), + "UV_TOOL_BIN_DIR": str(tmp_path / "bin"), + "UV_CACHE_DIR": str(tmp_path / "cache"), + "UV_PYTHON_INSTALL_DIR": str(tmp_path / "python"), + "UV_HTTP_RETRIES": "0", + "NO_PROXY": "127.0.0.1", + } + Path(env["HOME"]).mkdir() + + def install(*arguments, success=True, extra=None): + completed = subprocess.run( + [bash_command, str(ROOT / "website/public/install.sh"), "--no-hosts", *arguments], + cwd=tmp_path, + env=env | (extra or {}), + text=True, + capture_output=True, + timeout=30, + ) + assert (completed.returncode == 0) == success, completed.stdout + completed.stderr + return completed.stdout + completed.stderr + + def version(): + return subprocess.check_output([str(tmp_path / "bin/powercontext"), "--version"], env=env, text=True).strip() + + try: + yield install, version, Index, env + finally: + server.shutdown() + server.server_close() + worker.join() + + +@pytest.mark.parametrize("region", ["global", "cn"]) +def test_encoded_release_installs_through_public_script(installation, region): + install, version, _index, _env = installation + output = install("--region", region, "--version", "1.0.0") + assert version() == "1.0.0" + assert "Runtime installed: 1.0.0" in output + + +def test_latest_stable_and_explicit_prerelease(installation): + install, version, _index, _env = installation + install("--region", "cn") + assert version() == "1.1.0" + install("--region", "cn", "--version", "1.2.0rc1") + assert version() == "1.2.0rc1" + install("--region", "cn") + assert version() == "1.1.0" + + +def test_unavailable_automatic_mirror_falls_back_before_tool_install(installation): + install, version, index, _env = installation + index.mirror_status = 503 + output = install("--region", "cn", "--version", "1.0.0") + assert version() == "1.0.0" + assert "Automatic package mirror unavailable; using PyPI." in output + assert any(path.startswith("/global/") for path in index.requests) + + +def test_reachable_stale_mirror_preserves_request_and_installed_runtime(installation): + install, version, index, _env = installation + install("--region", "global", "--version", "1.1.0") + index.requests.clear() + index.mirror_versions = ["1.0.0"] + output = install("--region", "cn", "--version", "1.2.0rc1", success=False) + assert "Installation failed" in output + assert version() == "1.1.0" + assert not any(path.startswith("/global/") for path in index.requests) + + +def test_explicit_failed_index_has_no_automatic_fallback(installation): + install, _version, index, _env = installation + index.mirror_status = 403 + install("--region", "cn", "--index-url", "https://explicit.invalid/simple", "--version", "1.0.0", success=False) + assert not any(path.startswith("/global/") for path in index.requests) + + +def test_unrelated_config_preserved_and_region_does_not_override_index(installation): + install, version, index, env = installation + config = Path(env["XDG_CONFIG_HOME"]) / "uv/uv.toml" + config.parent.mkdir(parents=True) + config.write_text("compile-bytecode = false\n", encoding="utf-8") + original = config.read_bytes() + # Existing uv settings provide the local source while conservative suppression preserves config. + output = install("--region", "cn", "--version", "1.0.0", extra={"UV_DEFAULT_INDEX": "https://pypi.org/simple"}) + assert version() == "1.0.0" + assert "Using existing uv configuration" in output + assert config.read_bytes() == original + assert not any(path.startswith("/cn/") for path in index.requests) diff --git a/tests/native/test_installation.py b/tests/native/test_installation.py new file mode 100644 index 0000000000..5b9c20d39b --- /dev/null +++ b/tests/native/test_installation.py @@ -0,0 +1,382 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Acceptance through the installed CLI and a real HTTP Server, without model services.""" + +from __future__ import annotations + +import email +import os +import shutil +import socket +import subprocess +import sys +import time +import zipfile +from collections.abc import Iterator +from contextlib import contextmanager +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import Thread +from typing import Any + +import httpx +import pytest +from dotenv import dotenv_values + +ROOT = Path(__file__).parents[2] +WHEEL = os.environ.get("POWERCONTEXT_INSTALL_WHEEL") +pytestmark = pytest.mark.skipif(not WHEEL, reason="set POWERCONTEXT_INSTALL_WHEEL to run installation acceptance") + + +def run( + command: list[str], root: Path, env: dict[str, str], name: str, *, stdin: str = "", success: bool = True +) -> str: + with (root / f"{name}.log").open("w", encoding="utf-8") as log: + result = subprocess.run( + command, + cwd=root, + env=env, + input=stdin, + text=True, + stdout=log, + stderr=subprocess.STDOUT, + timeout=300, + check=False, + ) + output = (root / f"{name}.log").read_text(encoding="utf-8", errors="replace") + assert (result.returncode == 0) == success, output + return output + + +def request(url: str, token: str, body: dict[str, Any] | None = None) -> dict[str, Any]: + headers = {"Authorization": f"Bearer {token}"} if token else {} + with httpx.Client(trust_env=False, timeout=5, headers=headers) as client: + response = client.get(url) if body is None else client.post(url, json=body) + response.raise_for_status() + return response.json() + + +@contextmanager +def running_server(cli: str, root: Path, env: dict[str, str], token: str, name: str) -> Iterator[str]: + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + url = f"http://127.0.0.1:{port}" + log_path = root / f"{name}.log" + with log_path.open("w", encoding="utf-8") as log: + process = subprocess.Popen( + [cli, "server", "run", "--env-file", str(root / ".env"), "--host", "127.0.0.1", "--port", str(port)], + cwd=root, + env=env, + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + ) + try: + deadline = time.monotonic() + 90 + while time.monotonic() < deadline and process.poll() is None: + try: + request(url + "/health/ready", token) + break + except (httpx.TransportError, httpx.HTTPStatusError): + time.sleep(0.2) + else: + pytest.fail(log_path.read_text(encoding="utf-8", errors="replace")) + yield url + finally: + process.terminate() + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +def reinstall_offline( + installer: list[str], root: Path, env: dict[str, str], version: str, *, interactive: bool +) -> None: + if not interactive: + run(installer, root, env, "reinstall-offline") + return + # A piped default install succeeds without implicitly opening Agent selection. + controller = """import errno, os, pty, sys +pid, terminal = pty.fork() +if pid == 0: + os.execv('/bin/bash', ['/bin/bash', '-o', 'pipefail', '-c', + 'cat "$1" | /bin/bash -s -- --version "$2"', 'installer-pipeline', *sys.argv[1:]]) +try: + os.write(terminal, b'\\n') + while True: + try: + data = os.read(terminal, 4096) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not data: + break + os.write(1, data) +finally: + os.close(terminal) +_, status = os.waitpid(pid, 0) +sys.exit(os.waitstatus_to_exitcode(status)) +""" + output = run( + [sys.executable, "-c", controller, str(ROOT / "website/public/install.sh"), str(version)], + root, + env, + "reinstall-offline", + ) + assert "Runtime installed:" in output + assert "Select hosts" not in output + + +def utilities_without_python_or_uv(root: Path, system_path: str) -> Path: + """Keep OS utilities available while removing Python and uv from PATH discovery.""" + utilities = root / "utilities" + utilities.mkdir() + for directory in system_path.split(os.pathsep): + for executable in Path(directory).iterdir(): + if executable.name.startswith(("python", "pypy", "uv")) or not executable.is_file(): + continue + target = utilities / executable.name + if not target.exists(): + target.symlink_to(executable) + return utilities + + +@pytest.mark.parametrize("environment", ["bootstrap-global", "bootstrap-cn", "existing"]) +def test_install_configure_remember_and_reinstall(tmp_path: Path, environment: str) -> None: + assert WHEEL is not None + wheel = Path(WHEEL).resolve() + with zipfile.ZipFile(wheel) as archive: + metadata = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + version = email.message_from_bytes(archive.read(metadata))["Version"] + # The direct URL selects this build; a file URL hash fragment is not an integrity check. + constraints = tmp_path / "constraints.txt" + constraints.write_text(f"powercontext @ {wheel.as_uri()}\n", encoding="utf-8") + home_dir = tmp_path / "home with spaces 测试" + home_dir.mkdir() + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("UV_", "PIP_", "PYTHON", "POWERCONTEXT_")) and key != "VIRTUAL_ENV" + } + env.update( + HOME=str(home_dir), + USERPROFILE=str(home_dir), + # These subprocesses write UTF-8 log files, rather than a Windows console. + PYTHONIOENCODING="utf-8", + UV_TOOL_DIR=str(tmp_path / "tools"), + UV_TOOL_BIN_DIR=str(tmp_path / "bin"), + UV_CACHE_DIR=str(tmp_path / "cache"), + UV_PYTHON_INSTALL_DIR=str(tmp_path / "python"), + UV_NO_CONFIG="1", + UV_CONSTRAINT=str(constraints), + UV_NO_PROGRESS="1", + POWERCONTEXT_HOME=str(tmp_path / "state"), + ) + if sys.platform == "win32": + # Python inherits PowerShell 7 module paths, which Windows PowerShell cannot load. + env.pop("PSMODULEPATH", None) + shell = shutil.which("powershell.exe") + assert shell is not None + installer = [ + shell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(ROOT / "website/public/install.ps1"), + ] + if environment == "bootstrap-cn": + # Exercise the documented downloaded-scriptblock entry point as well as -File. + wrapper = tmp_path / "downloaded-installer.ps1" + script_path = str(ROOT / "website/public/install.ps1").replace("'", "''") + wrapper.write_text( + f"& ([scriptblock]::Create([IO.File]::ReadAllText('{script_path}'))) @args\n", encoding="utf-8" + ) + installer[-1] = str(wrapper) + cli = str(tmp_path / "bin/powercontext.exe") + system_path = os.pathsep.join([str(Path(os.environ["SYSTEMROOT"]) / "System32"), os.environ["SYSTEMROOT"]]) + else: + installer = ["/bin/bash", str(ROOT / "website/public/install.sh")] + cli = str(tmp_path / "bin/powercontext") + system_path = os.defpath + installer += ["--no-hosts", "--version", str(version)] + if environment.startswith("bootstrap"): + if sys.platform == "win32": + env["PATH"] = system_path + env["UV_PYTHON_NO_REGISTRY"] = "1" + else: + env["PATH"] = str(utilities_without_python_or_uv(tmp_path, system_path)) + env.update(TZ="Asia/Shanghai", LC_ALL="C", LANG="en_US.UTF-8") + if environment == "bootstrap-global": + env["POWERCONTEXT_INSTALL_REGION"] = "cn" + installer += ["--region", "global"] + else: + env.pop("UV_NO_CONFIG") + uv_config = tmp_path / "uv.toml" + uv_config.write_text('[[index]]\nurl = "https://pypi.org/simple"\ndefault = true\n', encoding="utf-8") + env.update( + TZ="UTC", + LC_ALL="zh_CN.UTF-8", + UV_CONFIG_FILE=str(uv_config), + UV_PYTHON_DOWNLOADS="never", + UV_PYTHON_INSTALL_MIRROR="https://127.0.0.1:9/unavailable", + POWERCONTEXT_UV_INSTALLER_URL="https://127.0.0.1:9/unavailable", + ) + run(installer, tmp_path, env, "install") + if environment == "existing": + # A fresh tool/cache forces resolution; exact reinstallation may legitimately reuse the existing tool. + failed_source_env = { + **env, + "UV_TOOL_DIR": str(tmp_path / "failed-source-tools"), + "UV_TOOL_BIN_DIR": str(tmp_path / "failed-source-bin"), + "UV_CACHE_DIR": str(tmp_path / "failed-source-cache"), + } + run( + [*installer, "--index-url", "https://127.0.0.1:9/simple"], + tmp_path, + failed_source_env, + "explicit-index", + success=False, + ) + assert run([cli, "--version"], tmp_path, env, "version").strip() == version + run([cli, "config", "init", "--template", "--output", ".env"], tmp_path, env, "configure", stdin="\n") + config = (tmp_path / ".env").read_bytes() + run([cli, "config", "validate", "--env-file", ".env"], tmp_path, env, "validate") + token = dotenv_values(tmp_path / ".env").get("POWERCONTEXT_SERVER_AUTH_TOKEN") or "" + text = "Installation acceptance preserves the release decision." + with running_server(cli, tmp_path, env, token, "server") as url: + scope = request(url + "/v1/scopes/default", token)["scope_id"] + request(url + "/v1/memory/remember", token, {"scope_id": scope, "kind": "decision", "text": text}) + query = {"scope_id": scope, "query": "release decision", "mode": "fts"} + found = request(url + "/v1/memory/search", token, query) + assert text in [hit["text"] for hit in found["hits"]] + env.update( + UV_OFFLINE="1", UV_PYTHON_DOWNLOADS="never", POWERCONTEXT_UV_INSTALLER_URL="https://127.0.0.1:9/unavailable" + ) + reinstall_offline( + installer, tmp_path, env, str(version), interactive=environment == "existing" and sys.platform != "win32" + ) + assert (tmp_path / ".env").read_bytes() == config + with running_server(cli, tmp_path, env, token, "server-restarted") as url: + found = request(url + "/v1/memory/search", token, query) + assert text in [hit["text"] for hit in found["hits"]] + + +def test_latest_exact_versions_and_client_profile(tmp_path: Path) -> None: + """Resolve real wheels from a controlled index with stable and prerelease candidates.""" + assert WHEEL is not None + uv = shutil.which("uv") + assert uv is not None + repository = tmp_path / "index" + packages = repository / "powercontext" + packages.mkdir(parents=True) + shutil.copyfile(WHEEL, packages / Path(WHEEL).name) + for version in ("1.2.1", "1.2.2rc1"): + run( + [uv, "build", "--project", str(ROOT), "--wheel", "--out-dir", str(packages)], + tmp_path, + {**os.environ, "SETUPTOOLS_SCM_PRETEND_VERSION": version}, + f"build-{version}", + ) + (packages / "index.html").write_text( + "\n".join(f'{wheel.name}' for wheel in sorted(packages.glob("*.whl"))), + encoding="utf-8", + ) + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("UV_", "PIP_", "PYTHON", "POWERCONTEXT_")) and key != "VIRTUAL_ENV" + } + env.update( + PYTHONIOENCODING="utf-8", + UV_NO_CONFIG="1", + UV_TOOL_DIR=str(tmp_path / "tools"), + UV_TOOL_BIN_DIR=str(tmp_path / "bin with spaces"), + UV_CACHE_DIR=str(tmp_path / "cache"), + UV_NO_PROGRESS="1", + POWERCONTEXT_HOME=str(tmp_path / "state"), + ) + if sys.platform == "win32": + shell = shutil.which("powershell.exe") + assert shell is not None + installer = [ + shell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(ROOT / "website/public/install.ps1"), + ] + cli = str(tmp_path / "bin with spaces/powercontext.exe") + python = str(tmp_path / "tools/powercontext/Scripts/python.exe") + else: + installer = ["/bin/bash", str(ROOT / "website/public/install.sh")] + cli = str(tmp_path / "bin with spaces/powercontext") + python = str(tmp_path / "tools/powercontext/bin/python") + server = ThreadingHTTPServer(("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, directory=str(repository))) + worker = Thread(target=server.serve_forever, daemon=True) + worker.start() + env["UV_INDEX"] = f"http://127.0.0.1:{server.server_port}" + try: + # Contradictory host intent must fail before any tool installation. + run([*installer, "--host", "codex", "--no-hosts"], tmp_path, env, "host-choice-conflict", success=False) + assert not Path(cli).exists() + client_install = [*installer, "--no-hosts", "--profile", "client"] + run([*client_install, "--version", "1.2.0"], tmp_path, env, "install-old") + assert run([cli, "--version"], tmp_path, env, "old-version").strip() == "1.2.0" + run(client_install, tmp_path, env, "install-latest") + assert run([cli, "--version"], tmp_path, env, "latest-version").strip() == "1.2.1" + if shutil.which("git"): + output = run( + [*installer, "--profile", "client", "--host", "unknown-host"], + tmp_path, + env, + "integration-failure", + success=False, + ) + assert "Runtime installed" in output + assert "powercontext-v1.2.1" in output + assert run([cli, "--version"], tmp_path, env, "retained-runtime").strip() == "1.2.1" + run( + [python, "-c", "from importlib.util import find_spec; assert find_spec('fastapi') is None"], + tmp_path, + env, + "client-without-server", + ) + assert not (tmp_path / "state").exists() + run([*client_install, "--version", "1.2.2rc1"], tmp_path, env, "install-prerelease") + assert run([cli, "--version"], tmp_path, env, "prerelease-version").strip() == "1.2.2rc1" + run([*client_install, "--version", "99.0.0"], tmp_path, env, "missing-version", success=False) + assert run([cli, "--version"], tmp_path, env, "preserved-version").strip() == "1.2.2rc1" + run(client_install, tmp_path, env, "stable-after-prerelease") + assert run([cli, "--version"], tmp_path, env, "restored-stable-version").strip() == "1.2.1" + run([*installer, "--no-hosts", "--version", "1.2.0"], tmp_path, env, "switch-to-local") + run([python, "-c", "import fastapi"], tmp_path, env, "local-includes-server") + run([*client_install, "--version", "1.2.0"], tmp_path, env, "switch-to-client") + run( + [python, "-c", "from importlib.util import find_spec; assert find_spec('fastapi') is None"], + tmp_path, + env, + "server-dependencies-removed", + ) + finally: + server.shutdown() + worker.join(timeout=5) + server.server_close() diff --git a/tests/native/test_service_installation.py b/tests/native/test_service_installation.py new file mode 100644 index 0000000000..a2d7cc9135 --- /dev/null +++ b/tests/native/test_service_installation.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Installed-script acceptance on disposable native macOS/Linux user-service runners.""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import sys +from pathlib import Path + +import httpx +import pytest + +from powercontext.cli.config import write_environment + +ROOT = Path(__file__).resolve().parents[2] +WHEEL = os.environ.get("POWERCONTEXT_INSTALL_WHEEL") +pytestmark = [ + pytest.mark.native_service, + pytest.mark.skipif( + os.environ.get("POWERCONTEXT_RUN_NATIVE_SERVICE_TESTS") != "1" + or not WHEEL + or sys.platform not in {"linux", "darwin"}, + reason="requires a release-shaped wheel and a disposable native user-service runner", + ), +] + + +def test_installer_service_reconciles_configuration_and_preserves_memory_after_upgrade(tmp_path: Path) -> None: + assert WHEEL is not None + uv = shutil.which("uv") + assert uv is not None + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("POWERCONTEXT_", "UV_", "PIP_", "PYTHON")) and key != "VIRTUAL_ENV" + } + constraints = tmp_path / "constraints.txt" + constraints.write_text(f"powercontext @ {Path(WHEEL).resolve().as_uri()}\n") + environment.update( + UV_CACHE_DIR=os.environ.get("UV_CACHE_DIR", str(Path.home() / ".cache" / "uv")), + UV_NO_CONFIG="1", + UV_OFFLINE="1", + UV_PYTHON_DOWNLOADS="never", + UV_CONSTRAINT=str(constraints), + UV_TOOL_DIR=str(tmp_path / "tools"), + UV_TOOL_BIN_DIR=str(tmp_path / "bin"), + ) + cli = str(tmp_path / "bin" / "powercontext") + configuration = tmp_path / "personal server.env" + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + token = "native-installation-token" # noqa: S105 + content = ( + f"POWERCONTEXT_HOME='{tmp_path / 'data'}'\n" + "POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1\n" + f"POWERCONTEXT_SERVER_HTTP_PORT={port}\n" + "POWERCONTEXT_SERVER_ACCESS_MODE=enforced\n" + f"POWERCONTEXT_SERVER_AUTH_TOKEN={token}\n" + ) + write_environment(configuration, content, backup=False) + installer = ["/bin/bash", str(ROOT / "website/public/install.sh"), "--region", "global"] + + def run(command: list[str], name: str) -> str: + result = subprocess.run(command, env=environment, capture_output=True, text=True, timeout=300) + output = result.stdout + result.stderr + (tmp_path / f"{name}.log").write_text(output) + assert result.returncode == 0, output + return result.stdout + + # This runner is disposable. Never replace an existing personal service. + before = subprocess.run( + [str(Path(sys.executable).with_name("powercontext")), "service", "status", "--json"], + env=environment, + capture_output=True, + text=True, + timeout=30, + ) + assert json.loads(before.stdout)["registration"] == "not_installed", before.stdout + before.stderr + try: + output = run([*installer, "--version", "1.2.0", "--service", "--env-file", str(configuration)], "install") + assert "Personal service verified with:" in output + status = json.loads(run([cli, "service", "status", "--json"], "status")) + assert status["manager"] == "active" + assert status["server_liveness"] == "live" + endpoint = f"http://127.0.0.1:{port}" + with httpx.Client(base_url=endpoint, headers={"Authorization": f"Bearer {token}"}, trust_env=False) as client: + scope = client.get("/v1/scopes/default").json()["scope_id"] + response = client.post( + "/v1/memory/remember", + json={"scope_id": scope, "kind": "decision", "text": "Keep native service memory."}, + ) + response.raise_for_status() + + content += "POWERCONTEXT_CODEX_SCOPE_ID=scope_example\n" + write_environment(configuration, content, backup=False) + # Stale status intentionally exits nonzero; inspect its public JSON separately. + stale = subprocess.run( + [cli, "service", "status", "--json"], env=environment, capture_output=True, text=True, timeout=30 + ) + assert stale.returncode == 1 + assert json.loads(stale.stdout)["definition"] == "stale" + run([*installer, "--version", "1.2.0", "--service", "--env-file", str(configuration)], "reconcile") + + run([cli, "service", "uninstall"], "stop-before-upgrade") + upgraded = tmp_path / "upgraded" + build = subprocess.run( + [uv, "build", "--offline", "--wheel", "--out-dir", str(upgraded)], + cwd=ROOT, + env={**environment, "SETUPTOOLS_SCM_PRETEND_VERSION": "1.2.1"}, + capture_output=True, + text=True, + timeout=300, + ) + assert build.returncode == 0, build.stdout + build.stderr + constraints.write_text(f"powercontext @ {next(upgraded.glob('*.whl')).as_uri()}\n") + run([*installer, "--version", "1.2.1", "--service", "--env-file", str(configuration)], "upgrade") + assert run([cli, "--version"], "upgraded-version").strip() == "1.2.1" + assert configuration.read_text() == content + with httpx.Client(base_url=endpoint, headers={"Authorization": f"Bearer {token}"}, trust_env=False) as client: + response = client.post( + "/v1/memory/search", json={"scope_id": scope, "query": "native service memory", "mode": "fts"} + ) + response.raise_for_status() + assert any(hit["text"] == "Keep native service memory." for hit in response.json()["hits"]) + finally: + if Path(cli).exists(): + run([cli, "service", "uninstall"], "cleanup") diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index d23f48f76b..008eb9a70e 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -48,10 +48,10 @@ def test_init_creates_a_model_free_deployment_and_explains_capability_limits(tmp assert "the value is never printed" in result.output assert "Configuration" in result.output assert "Supported Coding Agents (choose one)" in result.output - for host, (name, setup, launch) in config_cli.AGENTS.items(): + for host, (name, launch) in config_cli.AGENTS.items(): assert name in result.output - if host != "dsh": - assert setup in result.output + assert f" {host} --source " in result.output + assert f"--env-file {environment}" in result.output assert launch in result.output values = config_cli.parse_environment(environment.read_text(encoding="utf-8")) assert "POWERCONTEXT_SERVER_INFERENCE_GENERATION_MODEL" not in values @@ -63,22 +63,6 @@ def test_init_creates_a_model_free_deployment_and_explains_capability_limits(tmp assert shown.exit_code == 0 -@pytest.mark.parametrize("installed", ["0.2.0", "1.0.0rc1", "1.0.0rc2", "1.0.0", "1.1.0", "0.2.1.dev1+g1234567"]) -def test_init_matches_dsh_setup_to_installed_server(installed: str, tmp_path: Path, monkeypatch) -> None: - monkeypatch.setattr(config_cli, "version", lambda _name: installed) - monkeypatch.setattr(config_cli, "collect_configuration", lambda **_kwargs: _configuration()) - result = CliRunner().invoke( - config_cli.app, ["init", "--template", "--output", str(tmp_path / "server.env")], input="\n" - ) - assert result.exit_code == 0 - command = next(line.strip() for line in result.output.splitlines() if "powercontext setup dsh" in line) - if installed in {"0.2.0", "1.0.0rc1", "1.0.0rc2", "1.0.0", "1.1.0"}: - assert command.endswith(f"--ref powercontext-v{installed}") - else: - assert "--source /path/to/matching-powercontext-checkout" in command - assert "--ref master" not in command - - def test_arbitrary_model_providers_and_environment_variables_are_not_rejected() -> None: configuration = _configuration( generation=config_cli.ModelSelection( @@ -404,18 +388,28 @@ def test_init_refuses_to_replace_an_existing_environment_without_force( assert environment.read_text(encoding="utf-8") == "EXISTING=value\n" -def test_init_force_defaults_to_preserving_existing_inference_configuration(tmp_path: Path) -> None: +@pytest.mark.parametrize("require_write", [False, True]) +def test_init_force_defaults_to_preserving_existing_inference_configuration( + tmp_path: Path, require_write: bool +) -> None: environment = tmp_path / ".env" original = config_cli.render_managed_block(_configuration()) environment.write_text(original, encoding="utf-8") result = CliRunner().invoke( config_cli.app, - ["init", "--template", "--output", str(environment), "--force"], + [ + "init", + "--template", + "--output", + str(environment), + "--force", + *(["--require-write"] if require_write else []), + ], input="\n", ) - assert result.exit_code == 0 + assert result.exit_code == (130 if require_write else 0) assert "will remove existing model, embedding, inference schedule, or provider credential settings" in result.output assert "A mode-0600 backup will be created" in result.output assert "Replace them with a model-free configuration? [y/N]" in result.output diff --git a/tests/test_config_wizard.py b/tests/test_config_wizard.py index fe8094d20d..0b453fb38f 100644 --- a/tests/test_config_wizard.py +++ b/tests/test_config_wizard.py @@ -269,7 +269,7 @@ def test_openclaw_next_steps_use_plugin_configuration_contract(tmp_path: Path) - client = parse_environment(output.read_text()) assert not any(name.startswith("POWERCONTEXT_OPENCLAW_") for name in client) steps = output.with_name("server.env.next-steps.md").read_text() - assert "powercontext setup openclaw" in steps + assert f"powercontext setup --env-file {output} openclaw" in steps assert "--server-url http://127.0.0.1:17429" in steps assert "plugins.entries.memory-powercontext.config.endpoint" in steps assert "plugins.entries.memory-powercontext.config.autoCapture true" in steps @@ -298,19 +298,34 @@ def test_base_wizard_writes_private_environment_without_models(tmp_path: Path) - assert CliRunner().invoke(app, ["validate", "--env-file", str(output)]).exit_code == 0 -def test_cancel_in_chinese_does_not_write_files(tmp_path: Path) -> None: +@pytest.mark.parametrize("require_write", [False, True]) +def test_cancel_in_chinese_does_not_write_files(tmp_path: Path, require_write: bool) -> None: output = tmp_path / "server.env" result = CliRunner().invoke( app, - ["init", "--language", "zh", "--output", str(output)], + ["init", "--language", "zh", "--output", str(output), *(["--require-write"] if require_write else [])], input=f"sqlite\n{tmp_path / 'context.db'}\nlocal\nbase\nn\n\nnone\ny\nn\n", ) - assert result.exit_code == 0, result.output + assert result.exit_code == (130 if require_write else 0), result.output assert "配置向导" in result.output assert "未写入" in result.output assert not list(tmp_path.iterdir()) +def test_required_write_cancellation_preserves_existing_configuration(tmp_path: Path) -> None: + output = tmp_path / "server.env" + original = "POWERCONTEXT_SERVER_DATABASE_KIND=sqlite\nPOWERCONTEXT_SERVER_HTTP_PORT=18000\nCUSTOM_FLAG=keep\n" + output.write_text(original) + result = CliRunner().invoke( + app, + ["init", "--language", "en", "--output", str(output), "--require-write"], + input=f"sqlite\n{tmp_path / 'context.db'}\nreuse\nn\n", + ) + assert result.exit_code == 130, result.output + assert output.read_text() == original + assert not output.with_name("server.env.next-steps.md").exists() + + def test_existing_server_skips_server_models_and_storage(tmp_path: Path) -> None: output = tmp_path / "client.env" result = CliRunner().invoke( diff --git a/tests/test_config_wizard_installation.py b/tests/test_config_wizard_installation.py index 1412b38c05..0b427fc894 100644 --- a/tests/test_config_wizard_installation.py +++ b/tests/test_config_wizard_installation.py @@ -19,7 +19,9 @@ from pathlib import Path import pytest +from typer.testing import CliRunner +import powercontext.cli.config as config_cli import powercontext.cli.config_wizard as wizard from powercontext.cli.config_wizard_ui import WizardUI @@ -84,6 +86,39 @@ def test_checkout_instructions_keep_a_verified_local_source(tmp_path, monkeypatc assert "--ref" not in instructions +@pytest.mark.parametrize("installed", ["0.2.0", "1.0.0rc1", "1.0.0", "1.1.0"]) +def test_index_release_instructions_pin_every_agent_and_the_selected_file(tmp_path, monkeypatch, installed) -> None: + module = _installed_package(tmp_path, monkeypatch, None) + metadata = module.parents[2] / "powercontext-0.1.dist-info" / "METADATA" + metadata.write_text(f"Metadata-Version: 2.1\nName: powercontext\nVersion: {installed}\n") + monkeypatch.setattr(config_cli, "__file__", str(module)) + environment = tmp_path / "server.env" + + result = CliRunner().invoke(config_cli.app, ["init", "--template", "--output", str(environment)], input="\n") + + assert result.exit_code == 0, result.output + for agent in config_cli.AGENTS: + assert ( + f"powercontext setup --env-file {environment} {agent} --source oceanbase/powercontext " + f"--ref powercontext-v{installed}" + ) in result.output + instructions = _instructions() + for agent in ("codex", "claude-code"): + assert ( + f"powercontext setup {agent} --source oceanbase/powercontext --ref powercontext-v{installed}" + in instructions + ) + assert "--ref master" not in instructions + + +def test_unreleased_index_package_requires_an_explicit_matching_source(tmp_path, monkeypatch) -> None: + module = _installed_package(tmp_path, monkeypatch, None) + metadata = module.parents[2] / "powercontext-0.1.dist-info" / "METADATA" + metadata.write_text("Metadata-Version: 2.1\nName: powercontext\nVersion: 0.2.1.dev1+g1234567\n") + assert "powercontext setup codex --help" in _instructions() + assert "--ref master" not in _instructions() + + @pytest.mark.parametrize( "direct_url", [ diff --git a/tests/test_config_wizard_next_steps.py b/tests/test_config_wizard_next_steps.py index fe8b9eb2e7..e4855aebfd 100644 --- a/tests/test_config_wizard_next_steps.py +++ b/tests/test_config_wizard_next_steps.py @@ -77,11 +77,39 @@ def test_scope_binding_reloads_client_environment_before_agent_installation(tmp_ steps = wizard._next_steps(state, tmp_path / ".env", client_file) creation = steps.index("POST http://127.0.0.1:8000/v1/scopes") - installation = steps.index("powercontext setup claude-code") + installation = steps.index(" claude-code --source") assert f". {client_file}" in steps[creation:installation] + assert f"powercontext setup --env-file {client_file} claude-code" in steps assert "Reload the edited client environment before starting a new Agent" in steps +@pytest.mark.parametrize("platform", ["linux", "darwin", "win32"]) +def test_startup_guidance_matches_personal_service_support(tmp_path, monkeypatch, platform) -> None: + import powercontext.cli.config_startup as startup + + monkeypatch.setattr(startup.sys, "platform", platform) + state = _state() + environment = tmp_path / ".env" + steps = wizard._next_steps(state, environment, environment) + creation = steps.index("POST http://127.0.0.1:8000/v1/scopes") + installation = steps.index(" claude-code --source") + if platform == "win32": + assert "powercontext server run --env-file" in steps[:creation] + assert "powercontext service install" not in steps + else: + assert "powercontext service install --env-file" in steps[:creation] + assert "powercontext doctor --env-file" in steps[:creation] + assert "powercontext service install --env-file" in steps[creation:installation] + + +def test_managed_bind_keeps_foreground_startup_guidance(tmp_path) -> None: + state = _state() + state.values["POWERCONTEXT_SERVER_HTTP_HOST"] = "0.0.0.0" # noqa: S104 - managed-bind guidance + steps = wizard._next_steps(state, tmp_path / ".env", tmp_path / ".env") + assert "powercontext server run --env-file" in steps + assert "powercontext service install" not in steps + + def _profile_script() -> str: state = _state() state.features = {"profile"} diff --git a/tests/test_doctor_environment.py b/tests/test_doctor_environment.py new file mode 100644 index 0000000000..94b3bce673 --- /dev/null +++ b/tests/test_doctor_environment.py @@ -0,0 +1,118 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Explicit-file diagnostics reach the configured HTTP endpoint without shell evaluation.""" + +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread + +import pytest +from typer.testing import CliRunner + +from powercontext.cli.app import create_cli +from powercontext.cli.system import doctor_app + + +@pytest.fixture +def health_server(): + class Handler(BaseHTTPRequestHandler): + readiness = "ready" + + def log_message(self, format, *args): # noqa: A002 + pass + + def do_GET(self): + payload = ( + {"status": "ok"} + if self.path == "/health/live" + else {"status": self.readiness, "checks": {"database": "ready"}} + ) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(payload).encode()) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + yield server.server_port, Handler + finally: + server.shutdown() + server.server_close() + worker.join() + + +@pytest.mark.parametrize("server_file", [True, False]) +@pytest.mark.parametrize("readiness", ["ready", "degraded"]) +@pytest.mark.parametrize("lowercase_names", [False, True]) +def test_doctor_uses_selected_file_and_preserves_shell( + tmp_path, monkeypatch, health_server, server_file, readiness, lowercase_names +): + port, handler = health_server + handler.readiness = readiness + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_SERVER_URL", "http://127.0.0.1:1") + monkeypatch.setenv("POWERCONTEXT_SERVER_HTTP_PORT", "1") + monkeypatch.setenv("POWERCONTEXT_CLIENT_API_TOKEN", "caller-secret") + marker = tmp_path / "executed" + environment = tmp_path / "selected.env" + endpoint = f"http://127.0.0.1:{port}" + content = ( + f"POWERCONTEXT_SERVER_HTTP_HOST=127.0.0.1\nPOWERCONTEXT_SERVER_HTTP_PORT={port}\n" + "POWERCONTEXT_CLIENT_SERVER_URL=http://127.0.0.1:1\n" + if server_file + else f"POWERCONTEXT_CLIENT_SERVER_URL={endpoint}\n" + ) + if lowercase_names: + content = content.lower() + environment.write_text(content + f"POWERCONTEXT_CLIENT_API_TOKEN='file-secret'\nCUSTOM='$(touch {marker})'\n") + result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "--env-file", str(environment), "--json"]) + + assert result.exit_code == (0 if readiness == "ready" else 1), result.output + response = json.loads(result.output) + assert response["checks"]["server_readiness"]["detail"] == f"{endpoint} status={readiness}" + assert "file-secret" not in result.output + assert not marker.exists() + assert os.environ["POWERCONTEXT_CLIENT_API_TOKEN"] == "caller-secret" # noqa: S105 + assert os.environ["POWERCONTEXT_SERVER_HTTP_PORT"] == "1" + + +def test_doctor_explicit_url_overrides_file(tmp_path, health_server): + port, _handler = health_server + environment = tmp_path / "selected.env" + environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=1\n") + endpoint = f"http://127.0.0.1:{port}" + result = CliRunner().invoke( + create_cli([doctor_app]), ["doctor", "--env-file", str(environment), "--server-url", endpoint, "--json"] + ) + assert result.exit_code == 0, result.output + assert endpoint in json.loads(result.output)["checks"]["server_readiness"]["detail"] + + +def test_doctor_rejects_missing_environment_file(tmp_path): + result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "--env-file", str(tmp_path / "missing.env")]) + assert result.exit_code == 2 + assert "Cannot diagnose the environment file" in result.output + + +def test_doctor_rejects_file_option_for_integration_diagnostics(tmp_path): + environment = tmp_path / "selected.env" + environment.write_text("POWERCONTEXT_SERVER_HTTP_PORT=8000\n") + result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "--env-file", str(environment), "codex"]) + assert result.exit_code == 2 + assert "without an Agent selection" in result.output diff --git a/tests/test_installation_contract.py b/tests/test_installation_contract.py new file mode 100644 index 0000000000..f5581aae70 --- /dev/null +++ b/tests/test_installation_contract.py @@ -0,0 +1,269 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Native installer conformance with real uv and offline synthetic distributions.""" + +import csv +import io +import json +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CASES = [ + case + for path in sorted((ROOT / "tests/fixtures/installation").glob("*.json")) + for case in json.loads(path.read_text())["cases"] +] +CLI = """import importlib.util +import json +import os +from pathlib import Path +import sys + +def main(): + args = sys.argv[1:] + if args == ["--version"]: + print(os.environ.get("CONTRACT_REPORTED_VERSION", "1.2.0")) + elif not args or args[-1] == "--help": + command = " ".join(args[:-1]) if args else "" + missing = json.loads(os.environ.get("CONTRACT_MISSING_COMMANDS", "[]")) + if command in missing: + return 2 + if command == "server run": + import contract_server + print("Synthetic installation contract CLI") + elif args[:2] == ["config", "init"]: + if not sys.stdin.isatty(): + return 2 + status = int(os.environ.get("CONTRACT_CONFIG_EXIT", "0")) + if status == 0: + Path(args[args.index("--output") + 1]).write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\\n") + return status + elif args[:2] == ["config", "validate"]: + return int(os.environ.get("CONTRACT_VALIDATE_EXIT", "0")) + elif args[:2] == ["service", "install"]: + env_file = args[args.index("--env-file") + 1] + Path(os.environ["CONTRACT_SERVICE_RESULT"]).write_text(json.dumps({"env_file": env_file})) + return int(os.environ.get("CONTRACT_SERVICE_EXIT", "0")) + elif args[:2] == ["service", "status"]: + return 0 + elif args and args[0] == "doctor": + env_file = args[args.index("--env-file") + 1] + Path(os.environ["CONTRACT_DOCTOR_RESULT"]).write_text(json.dumps({"env_file": env_file})) + return int(os.environ.get("CONTRACT_DOCTOR_EXIT", "0")) + elif args and args[0] == "setup": + hosts = [] + index = 1 + while index < len(args): + if args[index].startswith("--"): + index += 2 + else: + hosts.append(args[index]) + index += 1 + ref = args[args.index("--ref") + 1] + selected = {"hosts": hosts, "ref": ref} + if "--env-file" in args: + selected["env_file"] = args[args.index("--env-file") + 1] + Path(os.environ["CONTRACT_SETUP_RESULT"]).write_text(json.dumps(selected)) + return int(os.environ.get("CONTRACT_SETUP_EXIT", "0")) + elif args == ["contract-capabilities"]: + print(json.dumps({"server_extra": importlib.util.find_spec("contract_server") is not None})) + else: + print("Synthetic installation contract CLI") + return 0 +""" + + +def wheel(directory: Path, name: str, files: dict[str, str], extra_metadata: str = "") -> None: + """Build a tiny standards-shaped wheel using only stdlib ZIP/CSV.""" + info = f"{name}-1.2.0.dist-info" + entries = files | { + f"{info}/METADATA": f"Metadata-Version: 2.3\nName: {name}\nVersion: 1.2.0\n{extra_metadata}\n", + f"{info}/WHEEL": "Wheel-Version: 1.0\nGenerator: installation-contract\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + } + record = io.StringIO() + writer = csv.writer(record) + writer.writerows((path, "", "") for path in entries) + writer.writerow((f"{info}/RECORD", "", "")) + entries[f"{info}/RECORD"] = record.getvalue() + with zipfile.ZipFile(directory / f"{name}-1.2.0-py3-none-any.whl", "w") as archive: + for path, content in entries.items(): + archive.writestr(path, content) + + +def executable_path(directory: Path, uv: str, windows: bool, include_git: bool) -> str: + """Expose real bootstrap prerequisites, optionally excluding Git.""" + tools = directory / "tools" + tools.mkdir() + # Real executables only; uv creates the platform-native console launcher. + executables = {"uv": uv} if windows else {"uv": uv, "python": sys.executable} + for name, executable in executables.items(): + destination = tools / (name + ".exe" if windows else name) + if windows: + shutil.copy2(executable, destination) + else: + destination.symlink_to(executable) + git = shutil.which("git") + if include_git and not git: + pytest.skip("explicit-host contract requires Git discovery") + if not windows: + for name in ("curl", "wget", "uname", "mktemp", "rm", "dirname", "cat", "sh", "readlink"): + executable = shutil.which(name) + if executable: + (tools / name).symlink_to(executable) + if include_git and git: + (tools / "git").symlink_to(git) + elif include_git and git: + # Presence check only: synthetic host setup never executes Git. + shutil.copy2(git, tools / "git.exe") + if windows: + return os.pathsep.join(( + str(tools), + str(Path(sys.executable).parent), + str(Path(os.environ["SYSTEMROOT"]) / "System32"), + )) + return str(tools) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case["id"]) +def test_installation_contract(case: dict[str, Any], tmp_path: Path) -> None: + uv = shutil.which("uv") + if not uv: + pytest.skip("native installation contract requires real uv") + windows = sys.platform == "win32" + if case.get("terminal") and windows: + pytest.skip("controlling-terminal pipe regression requires POSIX") + shell = shutil.which("powershell") or shutil.which("pwsh") if windows else shutil.which("bash") + if not shell: + pytest.skip("native installer shell is unavailable") + path = executable_path(tmp_path, uv, windows, case.get("git", False)) + packages = tmp_path / "packages" + packages.mkdir() + wheel(packages, "contract_server", {"contract_server.py": ""}) + wheel( + packages, + "powercontext", + { + "powercontext_contract.py": CLI, + "powercontext-1.2.0.dist-info/entry_points.txt": "[console_scripts]\npowercontext = powercontext_contract:main\n", + }, + "Provides-Extra: cli\nProvides-Extra: server\nRequires-Dist: contract-server==1.2.0; extra == 'server'\n", + ) + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("UV_", "POWERCONTEXT_", "PIP_", "PYTHON", "CONTRACT_")) and key != "VIRTUAL_ENV" + } + environment.update({ + "PYTHONIOENCODING": "utf-8", + "HOME": str(tmp_path), + "USERPROFILE": str(tmp_path), + "TMPDIR": str(tmp_path), + "PATH": path, + "UV_TOOL_DIR": str(tmp_path / "tool"), + "UV_TOOL_BIN_DIR": str(tmp_path / "bin"), + "UV_CACHE_DIR": str(tmp_path / "cache"), + "UV_NO_CONFIG": "1", + "UV_NO_INDEX": "1", + "UV_FIND_LINKS": str(packages), + "UV_PYTHON_DOWNLOADS": "never", + "UV_OFFLINE": "1", + "CONTRACT_SETUP_RESULT": str(tmp_path / "setup.json"), + "CONTRACT_SETUP_EXIT": str(case.get("setup_exit", 0)), + "CONTRACT_REPORTED_VERSION": case.get("reported_version", "1.2.0"), + "CONTRACT_MISSING_COMMANDS": json.dumps(case.get("missing_commands", [])), + "CONTRACT_VALIDATE_EXIT": str(case.get("validate_exit", 0)), + "CONTRACT_CONFIG_EXIT": str(case.get("config_exit", 0)), + "CONTRACT_SERVICE_EXIT": str(case.get("service_exit", 0)), + "CONTRACT_DOCTOR_EXIT": str(case.get("doctor_exit", 0)), + "CONTRACT_SERVICE_RESULT": str(tmp_path / "service.json"), + "CONTRACT_DOCTOR_RESULT": str(tmp_path / "doctor.json"), + }) + env_file = tmp_path / "existing configuration.env" + if case.get("environment_file"): + env_file.write_text("POWERCONTEXT_SERVER_HTTP_PORT=18000\n", encoding="utf-8") + env_file.chmod(0o600) + arguments = [str(env_file) if value == "{env_file}" else value for value in case["args"]] + script = ROOT / "website/public" / ("install.ps1" if windows else "install.sh") + command = ( + [shell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(script)] if windows else [shell, str(script)] + ) + if case.get("terminal"): + controller = """import errno, os, pty, sys +pid, terminal = pty.fork() +if pid == 0: + shell, script, *args = sys.argv[1:] + os.execv(shell, [shell, '-o', 'pipefail', '-c', + 'cat "$1" | "$2" -s -- "${@:3}"', 'installation', script, shell, *args]) +try: + while True: + try: + data = os.read(terminal, 4096) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not data: + break + os.write(1, data) +finally: + os.close(terminal) +_, status = os.waitpid(pid, 0) +sys.exit(os.waitstatus_to_exitcode(status)) +""" + command = [sys.executable, "-c", controller, *command] + result = subprocess.run( + [*command, "--region", "global", *arguments], env=environment, capture_output=True, text=True, timeout=60 + ) + assert_installation_outcome(case, tmp_path, result, environment) + + +def assert_installation_outcome(case, tmp_path, result, environment) -> None: + expected = case["expected"] + windows = sys.platform == "win32" + env_file = tmp_path / "existing configuration.env" + if "configuration_saved" in expected: + assert env_file.exists() == expected["configuration_saved"] + assert result.returncode == expected["exit_code"], result.stdout + result.stderr + executable = tmp_path / "bin" / ("powercontext.exe" if windows else "powercontext") + assert executable.exists() == expected["runtime_installed"] + setup = tmp_path / "setup.json" + observed = json.loads(setup.read_text()) if setup.exists() else {"hosts": []} + assert observed["hosts"] == expected.get("selected_hosts", []) + if "setup_ref" in expected: + assert observed["ref"] == expected["setup_ref"] + if expected.get("setup_env_file"): + assert observed["env_file"] == str(env_file) + for action in ("service", "doctor"): + observed_file = tmp_path / f"{action}.json" + assert observed_file.exists() == expected.get(action, False) + if observed_file.exists(): + assert json.loads(observed_file.read_text())["env_file"] == str(env_file) + if case.get("environment_file"): + assert env_file.read_text() == "POWERCONTEXT_SERVER_HTTP_PORT=18000\n" + if expected.get("verification_failed"): + assert "Runtime installed:" not in result.stdout + if "error_contains" in expected: + assert expected["error_contains"] in result.stdout + result.stderr + if expected["runtime_installed"] and "server_extra" in expected: + capabilities = subprocess.check_output([str(executable), "contract-capabilities"], env=environment, text=True) + assert json.loads(capabilities)["server_extra"] == expected["server_extra"] diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 9629fd7c07..4c7cb1a73c 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -86,11 +86,10 @@ def test_release_updates_installation_references_and_preserves_history(release_r for relative in INSTALL_PAGES: content = (release_repo / relative).read_text(encoding="utf-8") assert set(re.findall(r"powercontext\[[^\]\n]+\]==([^\s\"`]+)", content)) == {version}, relative - assert set(re.findall(r"powercontext-v([^\s\"`]+)", content)) == {version}, relative + assert set(re.findall(r"powercontext-v([0-9][^\s\"`]+)", content)) == {version}, relative if relative.startswith(("README", "docs/")) and not relative.endswith("/manual-start.md"): assert f"PowerContext {version}" in content, relative if re.search(r"(?:a|b|rc)[0-9]+$", version): - assert "stable release" not in content, relative assert f"stable PowerContext {version}" not in content, relative assert "正式版本" not in content, relative assert "正式リリース" not in content, relative @@ -98,8 +97,10 @@ def test_release_updates_installation_references_and_preserves_history(release_r english = (release_repo / "docs/en/docs/get-started/install-and-run.md").read_text(encoding="utf-8") chinese = (release_repo / "docs/zh/docs/get-started/install-and-run.md").read_text(encoding="utf-8") assert f"package `{version}`" in english + assert f"--version {version}" in english assert f"To upgrade to {version}:" in english assert f"Python 包版本为 `{version}`" in chinese + assert f"--version {version}" in chinese assert f"升级到 {version}" in chinese assert "Version 1.1.0 upgrades legacy" in english assert "1.0.0 also need" in english diff --git a/tests/test_service.py b/tests/test_service.py index ece742aaf1..52f9de6bf7 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -385,6 +385,21 @@ def test_service_controller_installs_and_starts_one_native_registration(tmp_path assert adapter.events == ["write", "reload", "enable", "start:True"] +def test_service_install_rejects_client_only_configuration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + environment = tmp_path / "client.env" + environment.write_text("POWERCONTEXT_CLIENT_SERVER_URL=https://memory.example.com\n") + environment.chmod(0o600) + _secure_windows_file(environment) + controller = ServiceController(FakeAdapter(tmp_path)) + monkeypatch.setattr(service_cli, "_controller", lambda: controller) + + result = CliRunner().invoke(service_app, ["install", "--env-file", str(environment), "--start-on-login"]) + + assert result.exit_code == 2, result.output + assert "contains only Client settings" in result.output + assert controller.registration_status().registration is RegistrationState.NOT_INSTALLED + + def test_service_controller_allows_slow_native_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: adapter = FakeAdapter(tmp_path) clock = 0.0 diff --git a/website/public/install.ps1 b/website/public/install.ps1 new file mode 100644 index 0000000000..8c51287048 --- /dev/null +++ b/website/public/install.ps1 @@ -0,0 +1,404 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Version = 'latest' +$UvVersion = '0.12.23' +$RuntimeProfile = 'local' +$Region = 'auto' +if ($env:POWERCONTEXT_INSTALL_REGION) { $Region = $env:POWERCONTEXT_INSTALL_REGION } +$IndexUrl = '' +$Hosts = @() +$NoHosts = $false +$Configure = $false +$InstallService = $false +$EnvFile = '' +$Uv = '' +$TempDir = $null + +function Show-Help { + Write-Host @' +Install PowerContext on Windows. Python and uv need not be installed. + +Usage: powershell -ExecutionPolicy Bypass -File install.ps1 [options] + + --version VERSION Exact release version, or latest (default: latest stable). + --profile PROFILE local (CLI and Server, default) or client (CLI and Client). + --region REGION auto, cn, or global (default: POWERCONTEXT_INSTALL_REGION or auto). + --index-url URL HTTPS default package index for this installation. + --host HOST Install an Agent integration; repeat for multiple hosts. + --no-hosts Compatibility option; integration setup is skipped by default. + --configure Open the configuration wizard in a terminal; requires --env-file. + --service Install and verify the personal service using --env-file. + --env-file PATH Explicit configuration file for configuration, service, and hosts. + -h, --help Show this help. + +Agent integration setup runs only for explicitly selected --host values. +After installation, run powercontext setup --ref "powercontext-v$(powercontext --version)". +Unattended service setup requires an existing protected environment file. +Windows service setup is experimental; --service enables login auto-start explicitly. + +Region priority: --region, POWERCONTEXT_INSTALL_REGION, named timezone, locale +territory, then global. No network location service is queried. +CN defaults: Tsinghua for PyPI, USTC for uv, NJU for Python. Global defaults use +PyPI and Astral's download channels. Unavailable automatic mirrors fall back to +official sources; explicit download settings are preserved without fallback. + +Download configuration (independent of the package index): + POWERCONTEXT_UV_INSTALLER_URL HTTPS uv PowerShell installer URL. + UV_DOWNLOAD_URL uv release artifact directory. + UV_INSTALLER_GITHUB_BASE_URL GitHub mirror base URL for uv binaries. + UV_PYTHON_INSTALL_MIRROR Mirror of Python distribution downloads. + UV_ASTRAL_MIRROR_URL Astral mirror for uv versions supporting it. +Existing additional uv indexes still take precedence over the default index. +'@ +} + +function Assert-HttpsUrl([string]$Value, [string]$Option) { + $Uri = $null + if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$Uri) -or + $Uri.Scheme -ne 'https' -or -not $Uri.Host -or $Value -match '[@?#\s]') { + throw "$Option must be an HTTPS URL without credentials, query parameters, or fragments." + } +} + +function Save-Download([string]$Url, [string]$Path, [int]$Timeout = 30) { + Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec $Timeout -OutFile $Path +} + +function Select-Region { + $Source = 'explicit' + if ($Region -eq 'auto') { + $Source = 'timezone' + $Timezone = [TimeZoneInfo]::Local.Id + if ($env:TZ) { $Timezone = $env:TZ.TrimStart(':') } + if ($Timezone -in @('Asia/Shanghai', 'Asia/Chongqing', 'Asia/Chungking', 'Asia/Harbin', 'Asia/Urumqi', 'PRC', 'China Standard Time')) { + $Region = 'cn' + } + elseif ($Timezone -match '^(Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)/' -or + ($Timezone -ne 'UTC' -and -not $env:TZ -and $Timezone -match 'Standard Time$')) { + $Region = 'global' + } + else { + $Source = 'locale' + $LocaleName = '' + foreach ($Name in @('LC_ALL', 'LC_MESSAGES', 'LANG')) { + $LocaleName = [Environment]::GetEnvironmentVariable($Name) + if ($LocaleName) { break } + } + if (-not $LocaleName -or $LocaleName -match '^(C($|\.)|POSIX$)') { $LocaleName = [Globalization.CultureInfo]::CurrentCulture.Name } + $Region = 'global' + if ($LocaleName -match '[_-]CN($|[.@])') { $Region = 'cn' } + } + } + Write-Host "Download region: $Region ($Source)." + return $Region +} + +function Test-UvConfigFile { + if ($env:UV_CONFIG_FILE) { return $true } + if ($env:UV_NO_CONFIG -in @('1', 'true')) { return $false } + foreach ($Directory in @($env:APPDATA, $env:PROGRAMDATA)) { + if ($Directory -and (Test-Path -LiteralPath (Join-Path $Directory 'uv\uv.toml'))) { return $true } + } + return $false +} + +function Test-UvConfiguration { + foreach ($Name in @('UV_DEFAULT_INDEX', 'UV_INDEX', 'UV_INDEX_URL', 'UV_EXTRA_INDEX_URL', + 'UV_OFFLINE', 'UV_NO_INDEX', 'UV_FIND_LINKS')) { + if ([Environment]::GetEnvironmentVariable($Name)) { return $true } + } + return (Test-UvConfigFile) +} + +function Test-DownloadUrl([string]$Url) { + try { + Invoke-WebRequest -Uri $Url -UseBasicParsing -Method Head -TimeoutSec 10 | Out-Null + return $true + } + catch { return $false } +} + +function Test-IndexAvailable([string]$Url) { + $IndexFile = Join-Path $TempDir 'index.html' + # Probe transport availability only; uv interprets the Simple API and versions. + try { Save-Download ($Url.TrimEnd('/') + '/powercontext/') $IndexFile } + catch { + Write-Host "Package mirror is unreachable: $Url" + return $false + } + return $true +} + +function Select-Index { + if (-not $IndexUrl -and (Test-UvConfiguration)) { + Write-Host 'Using existing uv configuration; uv will check the configured indexes.' + return + } + if ($env:PIP_INDEX_URL -or $env:PIP_EXTRA_INDEX_URL) { + Write-Host 'uv does not read pip index settings. Use --index-url or uv configuration.' + } + if (-not $IndexUrl) { + $IndexUrl = 'https://pypi.org/simple' + if ($Region -eq 'cn') { + $Mirror = 'https://pypi.tuna.tsinghua.edu.cn/simple' + Write-Host "Checking package mirror availability: $Mirror" + if (Test-IndexAvailable $Mirror) { $IndexUrl = $Mirror } + else { Write-Host 'Automatic package mirror unavailable; using PyPI.' } + } + } + # A reachable mirror may be stale or incompatible. Let uv report that failure + # without retrying a tool installation or changing the requested requirement. + Write-Host "Package index: $IndexUrl" + $env:UV_DEFAULT_INDEX = $IndexUrl + return $IndexUrl +} + +function Install-UvIfMissing { + $Command = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + $UserUv = Join-Path $HOME '.local\bin\uv.exe' + if ($env:UV_INSTALL_DIR) { $UserUv = Join-Path $env:UV_INSTALL_DIR 'uv.exe' } + if ($Command) { $Uv = $Command.Source } + elseif (Test-Path -LiteralPath $UserUv -PathType Leaf) { $Uv = $UserUv } + else { + if ($env:UV_OFFLINE -in @('1', 'true')) { throw 'uv is not installed and offline mode disables downloads.' } + Write-Host "Installing uv $UvVersion in the user executable directory." + $Url = "https://astral.sh/uv/$UvVersion/install.ps1" + if ($env:POWERCONTEXT_UV_INSTALLER_URL) { $Url = $env:POWERCONTEXT_UV_INSTALLER_URL } + $Installer = Join-Path $TempDir 'uv-install.ps1' + $CustomDownload = $env:POWERCONTEXT_UV_INSTALLER_URL -or $env:UV_DOWNLOAD_URL -or $env:INSTALLER_DOWNLOAD_URL -or + $env:UV_INSTALLER_GITHUB_BASE_URL -or $env:UV_INSTALLER_GHE_BASE_URL -or $env:UV_ASTRAL_MIRROR_URL + $Downloaded = $false + if ($Region -eq 'cn' -and -not $CustomDownload) { + $Mirror = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/$UvVersion" + try { + Write-Host "uv mirror: $Mirror" + Save-Download "$Mirror/uv-installer.ps1" $Installer + $Downloaded = $true + $env:UV_DOWNLOAD_URL = $Mirror + } + catch { Write-Host 'uv mirror installer unavailable; using the official installer.' } + } + if (-not $Downloaded) { + try { Save-Download $Url $Installer } + catch { throw 'Could not download uv. Check POWERCONTEXT_UV_INSTALLER_URL.' } + } + $env:UV_INSTALL_DIR = Split-Path -Parent $UserUv + $env:UV_NO_MODIFY_PATH = '1' + $PowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $Installer | Out-Host + if ($LASTEXITCODE -ne 0 -and $Downloaded) { + # The official PowerShell installer accepts only one UV_DOWNLOAD_URL. + Write-Host 'uv mirror installation failed; retrying uv with official sources.' + Remove-Item Env:UV_DOWNLOAD_URL + & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $Installer | Out-Host + } + if ($LASTEXITCODE -ne 0) { throw 'uv installation failed. Check the installer output and configured download source.' } + $Uv = $UserUv + } + if (-not (Test-Path -LiteralPath $Uv -PathType Leaf)) { throw 'uv executable was not found after installation.' } + Write-Host "Using uv: $Uv" + $env:PATH = "$(Split-Path -Parent $Uv);$env:PATH" + return $Uv +} + +$SavedEnvironment = @{} +foreach ($Name in @('UV_DEFAULT_INDEX', 'UV_INSTALL_DIR', 'UV_NO_MODIFY_PATH', 'UV_DOWNLOAD_URL')) { + $SavedEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name) +} +try { + for ($Index = 0; $Index -lt $args.Count; $Index++) { + $Option = $args[$Index] + switch ($Option) { + { $_ -in '--version', '--profile', '--region', '--index-url', '--host', '--env-file' } { + $Index++ + if ($Index -ge $args.Count -or -not $args[$Index] -or $args[$Index].StartsWith('--')) { + throw "$Option requires a value." + } + switch ($Option) { + '--version' { $Version = $args[$Index] } + '--profile' { $RuntimeProfile = $args[$Index] } + '--region' { $Region = $args[$Index] } + '--index-url' { $IndexUrl = $args[$Index] } + '--host' { $Hosts += @($args[$Index]) } + '--env-file' { $EnvFile = $args[$Index] } + } + } + '--no-hosts' { $NoHosts = $true } + '--configure' { $Configure = $true } + '--service' { $InstallService = $true } + { $_ -in '-h', '--help' } { Show-Help; exit 0 } + default { throw 'Unknown option. Run install.ps1 --help.' } + } + } + if ($Region -cnotin @('auto', 'cn', 'global')) { throw 'Use --region auto, cn, or global.' } + if ($RuntimeProfile -cnotin @('local', 'client')) { throw 'Use --profile local or client.' } + if (($Version -cne 'latest' -and $Version -notmatch '^\d+\.\d+\.\d+((a|b|rc)\d+)?$') -or $Version.StartsWith('0.0.')) { + throw 'Use latest or an exact package version starting at 0.1.0.' + } + if ($IndexUrl) { Assert-HttpsUrl $IndexUrl '--index-url' } + if ($env:POWERCONTEXT_UV_INSTALLER_URL) { Assert-HttpsUrl $env:POWERCONTEXT_UV_INSTALLER_URL 'POWERCONTEXT_UV_INSTALLER_URL' } + if ($NoHosts -and $Hosts.Count) { throw '--host and --no-hosts cannot be combined.' } + if ($Configure -or $InstallService) { + if ($RuntimeProfile -ne 'local') { throw '--configure and --service require --profile local.' } + if (-not $EnvFile) { throw '--configure and --service require --env-file PATH.' } + } + if ($EnvFile -and -not $Configure -and -not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) { + throw '--env-file must name an existing file unless --configure is selected.' + } + if ($Configure -and ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected)) { + throw '--configure requires a terminal. For unattended setup, use --service with an existing --env-file.' + } + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Use install.sh on macOS and Linux.' } + if ($Hosts.Count -and -not (Get-Command git -CommandType Application -ErrorAction SilentlyContinue)) { + throw 'Agent integration setup requires Git. Install Git or use --no-hosts.' + } + $TempDir = Join-Path ([IO.Path]::GetTempPath()) ('powercontext-install-' + [Guid]::NewGuid()) + [IO.Directory]::CreateDirectory($TempDir) | Out-Null + $Region = Select-Region + $Uv = Install-UvIfMissing + + $Python = $null + $FoundPython = $false + try { + $Python = & $Uv python find --system --no-project --no-python-downloads '>=3.11,<4' 2>$null + $FoundPython = $LASTEXITCODE -eq 0 -and $Python + } + catch { $FoundPython = $false } + if ($FoundPython) { + Write-Host "Using local Python: $Python" + } + else { + if ($env:UV_PYTHON_DOWNLOADS -in @('never', 'false', '0')) { throw 'No compatible local Python; UV_PYTHON_DOWNLOADS disables downloads.' } + if ($env:UV_OFFLINE -in @('1', 'true')) { throw 'No compatible local Python in offline mode.' } + $PythonArgs = @('python', 'install', '3.12') + $AutomaticMirror = $false + if ($Region -eq 'cn' -and -not ($env:UV_PYTHON_INSTALL_MIRROR -or $env:UV_ASTRAL_MIRROR_URL -or + $env:UV_PYTHON_DOWNLOADS_JSON_URL -or (Test-UvConfigFile))) { + $Downloads = & $Uv python list 'cpython@3.12' --only-downloads --show-urls --color never + if ($LASTEXITCODE -ne 0) { throw 'Could not resolve a Python download.' } + $Url = (@($Downloads)[0] -split '\s+')[-1] + if ($Url -match '^https://[^/]+/(?:github/|astral-sh/)?python-build-standalone/releases/download/(.+)$') { + $Mirror = 'https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone' + if (Test-DownloadUrl "$Mirror/$($Matches[1])") { + Write-Host "Python mirror: $Mirror" + $PythonArgs += @('--mirror', $Mirror) + $AutomaticMirror = $true + } + else { Write-Host 'Python mirror does not provide the requested build; using uv default sources.' } + } + } + Write-Host 'No compatible local Python found. Installing Python 3.12.' + & $Uv @PythonArgs + if ($LASTEXITCODE -ne 0 -and $AutomaticMirror) { + Write-Host 'Python mirror installation failed; retrying with uv default sources.' + & $Uv python install 3.12 + } + if ($LASTEXITCODE -ne 0) { throw 'Python installation failed. Check uv output and UV_PYTHON_INSTALL_MIRROR.' } + $Python = & $Uv python find --system --no-project --no-python-downloads 3.12 + if ($LASTEXITCODE -ne 0 -or -not $Python) { throw 'Installed Python was not found.' } + } + $IndexUrl = Select-Index + $Requirement = 'powercontext[cli,server]' + if ($RuntimeProfile -eq 'client') { $Requirement = 'powercontext[cli]' } + $InstallArgs = @('tool', 'install', '--python', $Python.Trim(), '--no-python-downloads') + # An installed prerelease otherwise remains eligible even with prereleases disallowed. + if ($Version -eq 'latest') { $InstallArgs += @('--upgrade', '--reinstall-package', 'powercontext', '--prerelease', 'disallow') } + else { $Requirement += "==$Version" } + $InstallArgs += $Requirement + if ($IndexUrl) { $InstallArgs += @('--default-index', $IndexUrl) } + & $Uv @InstallArgs + if ($LASTEXITCODE -ne 0) { throw 'Installation failed. Check uv indexes for packages and UV_PYTHON_INSTALL_MIRROR for Python.' } + $ToolBin = & $Uv tool dir --bin + if ($LASTEXITCODE -ne 0) { throw 'Could not locate the installed CLI.' } + $Cli = Join-Path $ToolBin.Trim() 'powercontext.exe' + $env:PATH = "$($ToolBin.Trim());$env:PATH" + $InstalledVersion = & $Cli --version + if ($LASTEXITCODE -ne 0) { throw 'Package installed, but CLI version verification failed. Check uv output and retry installation.' } + if ($InstalledVersion -notmatch '^\d+\.\d+\.\d+((a|b|rc)\d+)?$') { throw 'Package installed, but CLI release version verification failed. Check uv output and retry installation.' } + if ($Version -ne 'latest' -and $Version -ne $InstalledVersion) { throw 'Package installed, but the CLI version differs from the requested release. Retry installation with the intended version.' } + # Help checks import and command availability without configuring or starting a service. + & $Cli --help | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Package installed, but CLI help verification failed. Retry installation after reviewing the command error.' } + & $Cli capabilities --help | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Package installed, but Client command verification failed (capabilities --help). Retry installation after reviewing the command error.' } + if ($RuntimeProfile -eq 'local') { + & $Cli config init --help | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Package installed, but configuration command verification failed (config init --help). Retry installation after reviewing the command error.' } + & $Cli server run --help | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Package installed, but Server command verification failed (server run --help). Retry installation after reviewing the command error.' } + } + $Version = $InstalledVersion.Trim() + Write-Host "Runtime installed: $Version ($RuntimeProfile)" + $PathPrefix = ("$($ToolBin.Trim());$(Split-Path -Parent $Uv)").Replace("'", "''") + Write-Host ('For a new terminal: $env:Path = ''' + $PathPrefix + ';'' + $env:Path') + if ($Configure) { + & $Cli config init --output $EnvFile --require-write + if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but configuration did not complete. Review the command error or cancellation; retry config init before service setup.' } + Write-Host "Configuration saved: $EnvFile" + } + if ($EnvFile -and $RuntimeProfile -eq 'local') { + & $Cli config validate --env-file $EnvFile + if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but configuration validation failed. Correct the selected environment file before service setup.' } + Write-Host "Configuration validated: $EnvFile" + } + if ($InstallService) { + & $Cli service install --env-file $EnvFile --start-on-login + if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but personal service setup did not complete. Inspect service status and retry service install with the same --env-file.' } + & $Cli service status + if ($LASTEXITCODE -ne 0) { throw 'Runtime installed, but personal service verification failed. Inspect the native service logs.' } + & $Cli doctor --env-file $EnvFile + if ($LASTEXITCODE -ne 0) { throw 'Personal service installed, but Server diagnostics did not pass. Review readiness and retry doctor with the same --env-file.' } + Write-Host "Personal service verified with: $EnvFile" + } + $SetupStatus = 0 + if ($Hosts.Count) { + $SetupArgs = @('setup') + if ($EnvFile) { $SetupArgs += @('--env-file', $EnvFile) } + & $Cli @SetupArgs --source oceanbase/powercontext --ref "powercontext-v$Version" @Hosts + $SetupStatus = $LASTEXITCODE + } + if ($RuntimeProfile -eq 'local') { + if ($InstallService) { + Write-Host 'After upgrades or any environment-file edit, rerun service install with the same --env-file.' + } + else { + $NextEnv = '.env' + if ($EnvFile) { $NextEnv = $EnvFile } + $QuotedEnv = "'" + $NextEnv.Replace("'", "''") + "'" + if (-not $EnvFile) { Write-Host "`nConfigure: powercontext config init --output $QuotedEnv" } + Write-Host "Then run: powercontext service install --env-file $QuotedEnv" + Write-Host ' powercontext service status' + Write-Host " powercontext doctor --env-file $QuotedEnv" + Write-Host 'Windows personal services are experimental. For development or temporary use, run powercontext server run.' + } + Write-Host ' https://powercontext.oceanbase.io/en/docs/get-started/quickstart/' + } + else { + Write-Host "`nConnect to your existing Server:" + Write-Host ' https://powercontext.oceanbase.io/en/docs/operate/connect-remote-server/' + } + if ($SetupStatus -ne 0) { throw "Runtime installed, but integration setup did not complete. Retry setup with powercontext-v$Version." } +} +catch { + [Console]::Error.WriteLine("error: $($_.Exception.Message)") + exit 1 +} +finally { + foreach ($Name in $SavedEnvironment.Keys) { [Environment]::SetEnvironmentVariable($Name, $SavedEnvironment[$Name]) } + if ($TempDir) { Remove-Item -LiteralPath $TempDir -Recurse -Force -ErrorAction SilentlyContinue } +} diff --git a/website/public/install.sh b/website/public/install.sh new file mode 100644 index 0000000000..fec764240a --- /dev/null +++ b/website/public/install.sh @@ -0,0 +1,422 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +VERSION=latest +UV_VERSION=0.12.23 +PROFILE=local +REGION=${POWERCONTEXT_INSTALL_REGION:-auto} +PYTHON_BIN="" +INDEX_URL="" +HOSTS=() +NO_HOSTS=false +CONFIGURE=false +INSTALL_SERVICE=false +ENV_FILE="" +TEMP_DIR="" +UV_BIN="" +GUIDE=https://powercontext.oceanbase.io/en/docs/get-started/quickstart/ + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "$TEMP_DIR" ]]; then + rm -rf -- "$TEMP_DIR" + fi +} + +usage() { + cat <<'EOF' +Install PowerContext on macOS or Linux. Python and uv need not be installed. + +Usage: bash install.sh [--version VERSION] [--index-url URL] [--host HOST]... [--no-hosts] + + --version VERSION Exact package version, or latest (default: latest stable). + Integrations use the matching powercontext-vVERSION tag. + --profile PROFILE local (CLI and Server, default) or client (CLI and Client). + --region REGION auto, cn, or global (default: POWERCONTEXT_INSTALL_REGION or auto). + --index-url URL HTTPS default package index for this installation. + Existing additional uv indexes still take precedence. + --host HOST Install this Agent integration; repeat for multiple hosts. + --no-hosts Compatibility option; integration setup is skipped by default. + --configure Open the configuration wizard in a terminal; requires --env-file. + --service Install and verify the personal service using --env-file. + --env-file PATH Explicit configuration file for configuration, service, and hosts. + -h, --help Show this help. + +Agent integration setup runs only for explicitly selected --host values. +After installation, run powercontext setup --ref "powercontext-v$(powercontext --version)". +Personal macOS/Linux use: --configure --service --env-file /path/to/powercontext.env. +For unattended service setup, omit --configure and provide an existing protected file. + +Region priority: --region, POWERCONTEXT_INSTALL_REGION, named timezone, locale +territory, then global. No network location service is queried. +CN defaults: Tsinghua for PyPI, USTC for uv, NJU for Python. Global defaults use +PyPI and Astral's download channels. Unavailable automatic mirrors fall back to +official sources; explicit download settings are preserved without fallback. + +Download configuration (independent of the package index): + POWERCONTEXT_UV_INSTALLER_URL HTTPS uv installer script URL. + UV_DOWNLOAD_URL uv release artifact directory. + UV_INSTALLER_GITHUB_BASE_URL GitHub mirror base URL for uv binaries. + UV_PYTHON_INSTALL_MIRROR Mirror of python-build-standalone downloads. + UV_ASTRAL_MIRROR_URL Astral mirror for uv versions supporting it. +These settings are passed to the official installer and uv without modification. +Existing uv and compatible Python installations are reused. +EOF +} + +parse_args() { + while (($#)); do + case "$1" in + --version|--profile|--region|--index-url|--host|--env-file) + (($# >= 2)) && [[ -n "$2" && "$2" != --* ]] || fail "$1 requires a value" + case "$1" in + --version) VERSION=$2 ;; + --profile) PROFILE=$2 ;; + --region) REGION=$2 ;; + --index-url) INDEX_URL=$2 ;; + --host) HOSTS+=("$2") ;; + --env-file) ENV_FILE=$2 ;; + esac + shift 2 + ;; + --no-hosts) NO_HOSTS=true; shift ;; + --configure) CONFIGURE=true; shift ;; + --service) INSTALL_SERVICE=true; shift ;; + -h|--help) usage; exit 0 ;; + *) fail "Unknown option. Run bash install.sh --help." ;; + esac + done + case "$REGION" in auto|cn|global) ;; *) fail "Use --region auto, cn, or global." ;; esac + case "$PROFILE" in local|client) ;; *) fail "Use --profile local or client." ;; esac + [[ "$VERSION" == latest || "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]] || fail "Use latest or an exact package version." + [[ "$VERSION" != 0.0.* ]] || fail "This installer supports releases starting at 0.1.0." + if [[ -n "$INDEX_URL" ]]; then + [[ "$INDEX_URL" == https://?* && "$INDEX_URL" != *[@\?#[:space:]]* ]] || + fail "Use an HTTPS index URL without credentials, query parameters, or fragments." + fi + if [[ -n "${POWERCONTEXT_UV_INSTALLER_URL:-}" ]]; then + [[ "$POWERCONTEXT_UV_INSTALLER_URL" == https://?* && "$POWERCONTEXT_UV_INSTALLER_URL" != *[@\?#[:space:]]* ]] || + fail "POWERCONTEXT_UV_INSTALLER_URL must be an HTTPS URL without credentials, query parameters, or fragments." + fi + if [[ "$NO_HOSTS" == true && ${#HOSTS[@]} -gt 0 ]]; then + fail "--host and --no-hosts cannot be combined." + fi + if [[ "$CONFIGURE" == true || "$INSTALL_SERVICE" == true ]]; then + [[ "$PROFILE" == local ]] || fail "--configure and --service require --profile local." + [[ -n "$ENV_FILE" ]] || fail "--configure and --service require --env-file PATH." + fi + if [[ -n "$ENV_FILE" && "$CONFIGURE" == false ]]; then + [[ -f "$ENV_FILE" ]] || fail "--env-file must name an existing file unless --configure is selected." + fi +} + +download() { + local timeout=${3:-30} + if command -v curl >/dev/null 2>&1; then + curl --fail --location --silent --show-error --connect-timeout 5 --max-time "$timeout" "$1" -o "$2" + else + timeout=${3:-15} + wget --quiet --timeout="$timeout" --tries=1 "$1" -O "$2" + fi +} + +detect_region() { + local timezone=${TZ:-} locale_name source=explicit + if [[ "$REGION" == auto ]]; then + source=timezone + if [[ -z "$timezone" ]]; then + timezone=$(readlink /etc/localtime 2>/dev/null || true) + if [[ "$timezone" != */zoneinfo/* && -f /etc/timezone ]]; then + IFS= read -r timezone /dev/null || true) + fi + fi + case "$locale_name" in + *_CN|*_CN.*|*_CN@*|*-CN|*-CN.*|*-CN@*) REGION=cn ;; + *) REGION=global ;; + esac + ;; + esac + fi + printf 'Download region: %s (%s).\n' "$REGION" "$source" +} + +has_uv_config_file() { + [[ -n "${UV_CONFIG_FILE:-}" ]] && return 0 + case "${UV_NO_CONFIG:-}" in 1|true) return 1 ;; esac + [[ -f "${XDG_CONFIG_HOME:-$HOME/.config}/uv/uv.toml" || -f /etc/uv/uv.toml ]] && return 0 + local directory + local config_dirs=() + IFS=: read -r -a config_dirs <<< "${XDG_CONFIG_DIRS:-/etc/xdg}" + for directory in "${config_dirs[@]}"; do + [[ -f "$directory/uv/uv.toml" ]] && return 0 + done + return 1 +} + +has_uv_configuration() { + # Let uv resolve its configuration and authentication; do not parse TOML in shell. + [[ -n "${UV_DEFAULT_INDEX:-}${UV_INDEX:-}${UV_INDEX_URL:-}${UV_EXTRA_INDEX_URL:-}${UV_OFFLINE:-}${UV_NO_INDEX:-}${UV_FIND_LINKS:-}" ]] && return 0 + has_uv_config_file +} + +url_available() { + if command -v curl >/dev/null 2>&1; then + curl --fail --location --silent --head --connect-timeout 5 --max-time 10 "$1" >/dev/null + else + wget --quiet --spider --timeout=10 --tries=1 "$1" + fi +} + +index_available() { + # Probe transport availability only; uv interprets the Simple API and versions. + if ! download "${1%/}/powercontext/" "$TEMP_DIR/index.html"; then + printf 'Package mirror is unreachable: %s\n' "$1" >&2 + return 1 + fi +} + +check_index() { + if [[ -z "$INDEX_URL" ]] && has_uv_configuration; then + printf '%s\n' 'Using existing uv configuration; uv will check the configured indexes.' + return + fi + if [[ -n "${PIP_INDEX_URL:-}${PIP_EXTRA_INDEX_URL:-}" ]]; then + printf '%s\n' 'uv does not read PIP_INDEX_URL or PIP_EXTRA_INDEX_URL. Use --index-url or uv configuration.' + fi + if [[ -n "$INDEX_URL" ]]; then + # uv owns index authentication and package resolution, including private indexes. + printf 'Package index: %s\n' "$INDEX_URL" + return + fi + + INDEX_URL=https://pypi.org/simple + if [[ "$REGION" == cn ]]; then + local mirror=https://pypi.tuna.tsinghua.edu.cn/simple + printf 'Checking package mirror availability: %s\n' "$mirror" + if index_available "$mirror"; then + INDEX_URL=$mirror + else + printf '%s\n' 'Automatic package mirror unavailable; using PyPI.' + fi + fi + # A reachable mirror may be stale or incompatible. Let uv report that failure + # without retrying a tool installation or changing the requested requirement. + printf 'Package index: %s\n' "$INDEX_URL" +} + +ensure_uv() { + local uv_install_dir=${UV_INSTALL_DIR:-$HOME/.local/bin} + if command -v uv >/dev/null 2>&1; then + UV_BIN=$(command -v uv) + elif [[ -x "$uv_install_dir/uv" ]]; then + UV_BIN="$uv_install_dir/uv" + else + case "${UV_OFFLINE:-}" in 1|true) fail "uv is not installed and offline mode disables downloads." ;; esac + printf 'Installing uv %s in the user executable directory.\n' "$UV_VERSION" + local installer_url=${POWERCONTEXT_UV_INSTALLER_URL:-https://astral.sh/uv/$UV_VERSION/install.sh} + local mirror=https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/$UV_VERSION + local official=https://releases.astral.sh/github/uv/releases/download/$UV_VERSION + # A custom installer may install another version; never pair it with our pinned mirror. + if [[ "$REGION" == cn && -z "${POWERCONTEXT_UV_INSTALLER_URL:-}${UV_DOWNLOAD_URL:-}${INSTALLER_DOWNLOAD_URL:-}${UV_INSTALLER_GITHUB_BASE_URL:-}${UV_INSTALLER_GHE_BASE_URL:-}${UV_ASTRAL_MIRROR_URL:-}" ]]; then + printf 'uv mirror: %s\n' "$mirror" + if download "$mirror/uv-installer.sh" "$TEMP_DIR/uv-install.sh"; then + # The official shell installer supports an ordered list of artifact sources. + UV_DOWNLOAD_URL="$mirror $official https://github.com/astral-sh/uv/releases/download/$UV_VERSION" \ + UV_INSTALL_DIR="$uv_install_dir" UV_NO_MODIFY_PATH=1 sh "$TEMP_DIR/uv-install.sh" || fail "uv installation failed." + else + printf '%s\n' 'uv mirror unavailable; using the official installer.' + download "$installer_url" "$TEMP_DIR/uv-install.sh" || fail "Could not download the uv installer." + UV_INSTALL_DIR="$uv_install_dir" UV_NO_MODIFY_PATH=1 sh "$TEMP_DIR/uv-install.sh" || fail "uv installation failed." + fi + else + download "$installer_url" "$TEMP_DIR/uv-install.sh" || fail "Could not download the uv installer. Check POWERCONTEXT_UV_INSTALLER_URL." + UV_INSTALL_DIR="$uv_install_dir" UV_NO_MODIFY_PATH=1 sh "$TEMP_DIR/uv-install.sh" || fail "uv installation failed. Check the configured download source." + fi + UV_BIN="$uv_install_dir/uv" + fi + [[ -x "$UV_BIN" ]] || fail "uv executable was not found after installation." + printf 'Using uv: %s\n' "$UV_BIN" + PATH="$(dirname "$UV_BIN"):$PATH" + export PATH +} + +ensure_python() { + if PYTHON_BIN=$("$UV_BIN" python find --system --no-project --no-python-downloads '>=3.11,<4' 2>/dev/null); then + printf 'Using local Python: %s\n' "$PYTHON_BIN" + return + fi + case "${UV_PYTHON_DOWNLOADS:-}" in never|false|0) fail "No compatible local Python; UV_PYTHON_DOWNLOADS disables downloads." ;; esac + case "${UV_OFFLINE:-}" in 1|true) fail "No compatible local Python in offline mode." ;; esac + local mirror=https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone + local key url suffix automatic_mirror=false + local python_args=(python install 3.12) + if [[ "$REGION" == cn && -z "${UV_PYTHON_INSTALL_MIRROR:-}${UV_ASTRAL_MIRROR_URL:-}${UV_PYTHON_DOWNLOADS_JSON_URL:-}" ]] && ! has_uv_config_file; then + # Ask uv for the exact build URL; platform and archive selection stay with uv. + "$UV_BIN" python list 'cpython@3.12' --only-downloads --show-urls --color never >"$TEMP_DIR/python-downloads.txt" || fail "Could not resolve a Python download." + read -r key url <"$TEMP_DIR/python-downloads.txt" || true + case "$url" in + https://*/python-build-standalone/releases/download/*) + suffix=${url#*/python-build-standalone/releases/download/} + if url_available "$mirror/$suffix"; then + printf 'Python mirror: %s\n' "$mirror" + python_args+=(--mirror "$mirror") + automatic_mirror=true + else + printf '%s\n' 'Python mirror does not provide the requested build; using uv default sources.' + fi + ;; + esac + fi + printf '%s\n' 'No compatible local Python found. Installing Python 3.12.' + if ! "$UV_BIN" "${python_args[@]}"; then + if [[ "$automatic_mirror" == true ]]; then + printf '%s\n' 'Python mirror installation failed; retrying with uv default sources.' + "$UV_BIN" python install 3.12 || fail "Python installation failed. Check UV_PYTHON_INSTALL_MIRROR." + else + fail "Python installation failed. Check uv output and UV_PYTHON_INSTALL_MIRROR." + fi + fi + PYTHON_BIN=$("$UV_BIN" python find --system --no-project --no-python-downloads 3.12) || fail "Installed Python was not found." +} + +main() { + parse_args "$@" + case "$(uname -s)" in + Darwin|Linux) ;; + *) fail "Use the Windows instructions in the installation guide. This script supports macOS and Linux." ;; + esac + [[ -n "${HOME:-}" ]] || fail "HOME is not set." + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || fail "Install curl or wget first." + if [[ "$CONFIGURE" == true ]]; then + # The downloaded script owns stdin; give only the opt-in wizard the controlling terminal. + [[ -t 1 ]] && { exec 3/dev/null || + fail "--configure requires a terminal. For unattended setup, use --service with an existing --env-file." + fi + if [[ ${#HOSTS[@]} -gt 0 ]]; then + command -v git >/dev/null 2>&1 || fail "Agent integration setup requires Git. Install Git or use --no-hosts." + fi + TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/powercontext-install.XXXXXX") + trap cleanup EXIT + detect_region + ensure_uv + ensure_python + check_index + [[ -z "$INDEX_URL" ]] || export UV_DEFAULT_INDEX="$INDEX_URL" + + local requirement='powercontext[cli,server]' + [[ "$PROFILE" != client ]] || requirement='powercontext[cli]' + local install_args=(tool install --python "$PYTHON_BIN" --no-python-downloads) + if [[ "$VERSION" == latest ]]; then + # An installed prerelease otherwise remains eligible even with prereleases disallowed. + install_args+=(--upgrade --reinstall-package powercontext --prerelease disallow) + else + requirement="$requirement==$VERSION" + fi + install_args+=("$requirement") + [[ -z "$INDEX_URL" ]] || install_args+=(--default-index "$INDEX_URL") + printf 'Installing PowerContext %s.\n' "$VERSION" + "$UV_BIN" "${install_args[@]}" || + fail "Installation failed. Check the uv error: Python downloads use UV_PYTHON_INSTALL_MIRROR or UV_ASTRAL_MIRROR_URL; Python packages use uv indexes." + + local tool_bin + tool_bin=$("$UV_BIN" tool dir --bin) + export PATH="$tool_bin:$PATH" + local installed_version + installed_version=$("$tool_bin/powercontext" --version) || fail "Package installed, but CLI version verification failed. Check uv output and retry installation." + [[ "$installed_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]] || fail "Package installed, but CLI release version verification failed. Check uv output and retry installation." + [[ "$VERSION" == latest || "$VERSION" == "$installed_version" ]] || fail "Package installed, but the CLI version differs from the requested release. Retry installation with the intended version." + # Help checks import and command availability without configuring or starting a service. + "$tool_bin/powercontext" --help >/dev/null || + fail "Package installed, but CLI help verification failed. Retry installation after reviewing the command error." + "$tool_bin/powercontext" capabilities --help >/dev/null || + fail "Package installed, but Client command verification failed (capabilities --help). Retry installation after reviewing the command error." + if [[ "$PROFILE" == local ]]; then + "$tool_bin/powercontext" config init --help >/dev/null || + fail "Package installed, but configuration command verification failed (config init --help). Retry installation after reviewing the command error." + "$tool_bin/powercontext" server run --help >/dev/null || + fail "Package installed, but Server command verification failed (server run --help). Retry installation after reviewing the command error." + fi + VERSION=$installed_version + printf 'Runtime installed: %s (%s)\n' "$VERSION" "$PROFILE" + printf 'For a new terminal, add these directories to PATH if needed:\n' + printf ' export PATH=%q:%q:"%s"\n' "$(dirname "$UV_BIN")" "$tool_bin" "\$PATH" + + if [[ "$CONFIGURE" == true ]]; then + "$tool_bin/powercontext" config init --output "$ENV_FILE" --require-write <&3 || + fail "Runtime installed, but configuration did not complete. Review the command error or cancellation; retry config init before service setup." + printf 'Configuration saved: %s\n' "$ENV_FILE" + fi + if [[ -n "$ENV_FILE" && "$PROFILE" == local ]]; then + "$tool_bin/powercontext" config validate --env-file "$ENV_FILE" || + fail "Runtime installed, but configuration validation failed. Correct the selected environment file before service setup." + printf 'Configuration validated: %s\n' "$ENV_FILE" + fi + if [[ "$INSTALL_SERVICE" == true ]]; then + "$tool_bin/powercontext" service install --env-file "$ENV_FILE" || + fail "Runtime installed, but personal service setup did not complete. Inspect service status and retry service install with the same --env-file." + "$tool_bin/powercontext" service status || + fail "Runtime installed, but personal service verification failed. Inspect the native service logs." + "$tool_bin/powercontext" doctor --env-file "$ENV_FILE" || + fail "Personal service installed, but Server diagnostics did not pass. Review readiness and retry doctor with the same --env-file." + printf 'Personal service verified with: %s\n' "$ENV_FILE" + fi + + local setup_status=0 + if [[ ${#HOSTS[@]} -gt 0 ]]; then + local setup_args=(setup) + [[ -z "$ENV_FILE" ]] || setup_args+=(--env-file "$ENV_FILE") + "$tool_bin/powercontext" "${setup_args[@]}" --source oceanbase/powercontext \ + --ref "powercontext-v$VERSION" ${HOSTS[@]+"${HOSTS[@]}"} || setup_status=$? + fi + if [[ "$PROFILE" == local ]]; then + if [[ "$INSTALL_SERVICE" == true ]]; then + printf '%s\n' 'After upgrades or any environment-file edit, rerun service install with the same --env-file.' + else + local next_env=${ENV_FILE:-.env} + [[ -n "$ENV_FILE" ]] || printf '\nConfigure: powercontext config init --output %q\n' "$next_env" + printf 'Then run: powercontext service install --env-file %q\n' "$next_env" + printf ' powercontext service status\n powercontext doctor --env-file %q\n' "$next_env" + printf '%s\n' 'A native user-service manager and loopback Server bind are required. For development or temporary use, run powercontext server run.' + fi + printf ' %s\n' "$GUIDE" + else + printf '\nConnect to your existing Server:\n %s\n' \ + 'https://powercontext.oceanbase.io/en/docs/operate/connect-remote-server/' + fi + if ((setup_status != 0)); then + fail "Runtime installed, but integration setup did not complete. Review its results and retry setup with powercontext-v$VERSION." + fi +} + +main "$@"