From 857e6155c565488cbfb6cb5ca941211257e87db1 Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Mon, 5 Oct 2026 20:25:52 +0530 Subject: [PATCH 1/3] test: exercise Windows installer in a real terminal --- .github/workflows/install-smoke.yml | 20 ++- src/tests/README.md | 3 + src/tests/native/windows-install-terminal.py | 180 +++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/tests/native/windows-install-terminal.py diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 8dee5e9..33ddf6f 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -145,10 +145,26 @@ jobs: - name: Smoke the installed chargebee.exe run: bash scripts/install-smoke-assert.sh + # CI=1 deliberately skips prompts in the snapshot jobs above. Exercise the + # released executable through real Windows console input in both PS editions. + windows-terminal-install: + runs-on: windows-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shell: [powershell, pwsh] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install ConPTY test driver + run: python -m pip install --disable-pip-version-check pywinpty==3.0.5 + - name: Test released Windows installer with real terminal input + run: python src/tests/native/windows-install-terminal.py --shell ${{ matrix.shell }} --release v1.4.0 + required-checks: name: Install required checks if: ${{ always() }} - needs: [github-install, npm-install, windows-install] + needs: [github-install, npm-install, windows-install, windows-terminal-install] runs-on: ubuntu-latest steps: - name: Verify required job results @@ -156,6 +172,7 @@ jobs: GITHUB_INSTALL_RESULT: ${{ needs.github-install.result }} NPM_INSTALL_RESULT: ${{ needs.npm-install.result }} WINDOWS_INSTALL_RESULT: ${{ needs.windows-install.result }} + WINDOWS_TERMINAL_INSTALL_RESULT: ${{ needs.windows-terminal-install.result }} run: | set -euo pipefail require_result() { @@ -167,3 +184,4 @@ jobs: require_result github-install "$GITHUB_INSTALL_RESULT" success require_result npm-install "$NPM_INSTALL_RESULT" success require_result windows-install "$WINDOWS_INSTALL_RESULT" success + require_result windows-terminal-install "$WINDOWS_TERMINAL_INSTALL_RESULT" success diff --git a/src/tests/README.md b/src/tests/README.md index 7a256d9..de51d24 100644 --- a/src/tests/README.md +++ b/src/tests/README.md @@ -8,10 +8,13 @@ | npm-specific behavior | `bun run test:npm` | Shared CLI suite plus detached telemetry-child tests | Linux, macOS, Windows × Node 22/24 | None | | Native credentials | `bun run test:os` | Actual OS credential adapters | Linux, macOS, Windows | Provisioned disposable credential session | | Installation | Install-channel smoke workflow | Installs this commit's artifacts and runs the installed CLI | Supported OS/channel combinations; npm Node 22/24 | Package tooling | +| Windows installer prompts | Install-channel smoke workflow | Real ConPTY input, release download and checksum, skill and alias consent | Windows x64; PowerShell 5.1 and 7 | Python, pinned pywinpty, GitHub release v1.4.0 | | Live API | `bun run test:live` | Compiled native CLI | Linux, macOS, Windows, serialized | US site fixtures, read-only live-site key, public US tunnel | CI and installation checks run in full on all PRs and pushes to main. Release automation waits for both workflows to succeed on its exact main commit. See [release installation checks](../../CONTRIBUTING.md#release-installation-checks). +The Windows terminal test serves this checkout's unchanged `install.ps1` over loopback and runs `irm ... | iex` with the released executable. It removes the inherited CI flag and isolates credentials, agent directories, the install directory, and the PowerShell profile. Yes, No, and Enter must reach both onboarding prompts and exit within two minutes per case. Accepted installations must use global skill paths; declined installations must leave skills and aliases absent. Run `python src/tests/native/windows-install-terminal.py --shell powershell --release v1.4.0` (or `--shell pwsh`) only in a disposable Windows session with `pywinpty==3.0.5` installed. + Live tests run daily at 06:00 UTC or by manual dispatch, after source quality checks for the same commit. Only source tests contribute to the 95% per-file coverage gate; coverage of the Bun test process does not measure separately compiled CLI processes. `bun run test` runs source and CLI suites. Direct `bun test` still discovers all folders; live and real-vault tests remain guarded. Prefer the explicit scripts in automation. CI requires `CB_REQUIRE_BINARY=1` so artifact tests cannot accidentally fall back to source execution. diff --git a/src/tests/native/windows-install-terminal.py b/src/tests/native/windows-install-terminal.py new file mode 100644 index 0000000..65ec4c6 --- /dev/null +++ b/src/tests/native/windows-install-terminal.py @@ -0,0 +1,180 @@ +"""Exercise the released Windows CLI through the installer's real console prompts. + +Run only in a disposable Windows session; requires pywinpty. The loopback server +serves this checkout's unchanged installer, while release downloads and checksum +verification use GitHub. No API credentials or prompt mocks are involved. +""" + +import argparse +import base64 +import http.server +import os +from pathlib import Path +import queue +import re +import shutil +import subprocess +import tempfile +import threading +import time + + +def plain(text): + return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text) + + +def run_case(shell, release, url, answer): + from winpty import PtyProcess + from winpty.enums import Backend + + with tempfile.TemporaryDirectory(prefix="cb-windows-terminal-") as temp: + root = Path(temp) + target_home = root / "home" + for agent in (".cursor", ".claude", ".codex"): + (target_home / agent).mkdir(parents=True) + profile = root / "profile.ps1" + # Do not inherit CI=1 (which disables onboarding), tokens, or a user's + # configured agent directories. All resulting files stay in this fixture. + keep = {"SYSTEMROOT", "WINDIR", "COMSPEC", "PATH", "PATHEXT", "OS", + "PROCESSOR_ARCHITECTURE", "TEMP", "TMP"} + env = {key.upper(): value for key, value in os.environ.items() if key.upper() in keep} + env.update({ + "HOME": str(target_home), "USERPROFILE": str(target_home), + "APPDATA": str(root / "AppData" / "Roaming"), + "LOCALAPPDATA": str(root / "AppData" / "Local"), + "CHARGEBEE_CONFIG_DIR": str(root / "config"), + "CHARGEBEE_CLI_BIN_DIR": str(root / "bin"), + "CHARGEBEE_CLI_VERSION": release, "DO_NOT_TRACK": "1", + "TERM": "xterm-256color", + }) + # Override only the destination of alias writes; the real Read-Host and + # CLI prompts run inside ConPTY, including the nested picker if present. + escaped_profile = str(profile).replace("'", "''") + command = ( + "$ErrorActionPreference = 'Stop'; " + f"$PROFILE = '{escaped_profile}'; " + f"try {{ irm '{url}' | iex; Write-Output '__CB_INSTALL_COMPLETE__'; exit 0 }} " + "catch { Write-Output $_; exit 1 }" + ) + encoded = base64.b64encode(command.encode("utf-16-le")).decode("ascii") + proc = PtyProcess.spawn( + [shell, "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], + cwd=temp, env=env, dimensions=(60, 200), backend=Backend.ConPTY, + ) + chunks = queue.Queue() + + def read_output(): + try: + while True: + chunks.put(proc.read(4096)) + except (EOFError, OSError): + chunks.put(None) + + threading.Thread(target=read_output, daemon=True).start() + output = "" + skill_answered = alias_answered = picker_answered = False + cursor_queries = 0 + started = time.monotonic() + exited_at = None + try: + while time.monotonic() - started < 120: + try: + chunk = chunks.get(timeout=0.1) + except queue.Empty: + chunk = "" + if chunk: + output += chunk + # ConPTY/PowerShell may query the terminal cursor location. + queries = output.count("\x1b[6n") + while cursor_queries < queries: + proc.write("\x1b[1;1R") + cursor_queries += 1 + visible = plain(output) + if not skill_answered and re.search(r"Install the Chargebee CLI skill.*?\[Y/n\]", visible): + assert not list(target_home.rglob("SKILL.md")), "Skill installed before consent" + proc.write(answer + "\r") + skill_answered = True + if not picker_answered and "Select agents" in visible: + # Baseline behavior: accept the detected agents just as + # a user would, so a picker input hang hits the deadline. + proc.write("\r") + picker_answered = True + if not alias_answered and re.search(r"Add a cb shortcut.*?\[Y/n\]", visible): + assert not profile.exists(), "Alias written before consent" + proc.write(answer + "\r") + alias_answered = True + if not proc.isalive(): + exited_at = exited_at or time.monotonic() + if chunk is None or time.monotonic() - exited_at > 1: + break + assert not proc.isalive(), "Installer timed out waiting for terminal input" + assert proc.exitstatus == 0, f"Installer exit status: {proc.exitstatus}" + assert skill_answered and alias_answered, "Installer skipped onboarding prompts" + assert "__CB_INSTALL_COMPLETE__" in output, "Installer did not complete" + binary = root / "bin" / "chargebee.exe" + version = subprocess.check_output([str(binary), "--version"], env=env, text=True, timeout=15).strip() + assert version == release.removeprefix("v"), version + skills = list(target_home.rglob("SKILL.md")) + if answer == "n": + assert not skills, f"Declining installed skills: {skills}" + assert not profile.exists(), "Declining created an alias" + else: + expected = {target_home / agent / "skills" / "chargebee-cli" / "SKILL.md" + for agent in (".cursor", ".claude", ".codex")} + assert set(skills) == expected, f"Incorrect global skill paths: {skills}" + assert all("name: chargebee-cli" in path.read_text(encoding="utf-8") for path in skills) + assert "Set-Alias cb" in profile.read_text(encoding="utf-8"), "Alias was not installed" + print(f"PASS {Path(shell).name}: answer={answer or 'Enter'}, version={version}, " + f"skills={len(skills)}, picker={picker_answered}, seconds={time.monotonic()-started:.1f}", flush=True) + except Exception: + print(plain(output), flush=True) + raise + finally: + if proc.isalive(): + subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, timeout=15) + proc.close(force=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--shell", choices=("powershell", "pwsh"), required=True) + parser.add_argument("--release", required=True) + args = parser.parse_args() + if os.name != "nt": + parser.error("This test requires a disposable Windows session") + shell = shutil.which(args.shell) + if not shell: + parser.error(f"Missing shell: {args.shell}") + installer = Path(__file__).resolve().parents[3] / "install.ps1" + + class InstallerHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = installer.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), InstallerHandler) + threading.Thread(target=server.serve_forever, daemon=True).start() + failures = [] + try: + for answer in ("y", "n", ""): + try: + run_case(shell, args.release, f"http://127.0.0.1:{server.server_port}/install.ps1", answer) + except Exception as error: + failures.append(f"{answer or 'Enter'}: {error}") + print(f"FAIL {args.shell}: {failures[-1]}", flush=True) + finally: + server.shutdown() + server.server_close() + if failures: + raise SystemExit("\n".join(failures)) + + +if __name__ == "__main__": + main() From 1e0295d79c6c838ec9faf6d24d4378e72086f1a8 Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Mon, 5 Oct 2026 20:29:30 +0530 Subject: [PATCH 2/3] test: preserve Unicode Windows terminal transcripts --- src/tests/native/windows-install-terminal.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tests/native/windows-install-terminal.py b/src/tests/native/windows-install-terminal.py index 65ec4c6..4d6fac5 100644 --- a/src/tests/native/windows-install-terminal.py +++ b/src/tests/native/windows-install-terminal.py @@ -14,6 +14,7 @@ import re import shutil import subprocess +import sys import tempfile import threading import time @@ -27,6 +28,7 @@ def run_case(shell, release, url, answer): from winpty import PtyProcess from winpty.enums import Backend + print(f"START {Path(shell).name}: answer={answer or 'Enter'}", flush=True) with tempfile.TemporaryDirectory(prefix="cb-windows-terminal-") as temp: root = Path(temp) target_home = root / "home" @@ -136,6 +138,10 @@ def read_output(): def main(): + # GitHub captures stdout through a pipe; Windows' legacy code page cannot + # encode the CLI's arrows and picker glyphs in failure transcripts. + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--shell", choices=("powershell", "pwsh"), required=True) parser.add_argument("--release", required=True) From 3a9caf6359944a5c76a01c3e919bade916efc2e6 Mon Sep 17 00:00:00 2001 From: cb-logesh Date: Mon, 5 Oct 2026 20:33:00 +0530 Subject: [PATCH 3/3] fix: install Windows agent skills globally after consent --- README.md | 4 ++-- install.ps1 | 10 ++++++---- src/tests/native/windows-install-terminal.py | 14 +++++--------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 33cf9e6..be5cf60 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,14 @@ The Chargebee CLI is available for macOS, Linux, and Windows. curl -fsSL https://raw.githubusercontent.com/chargebee/cli/main/install.sh | bash ``` -If you accept the skill installation prompt, the installer adds the skill for every detected coding agent. To choose agents yourself, answer No and then run `chargebee skills add --global --agent cursor` (repeat `--agent` for other agents). - **Windows** (PowerShell) ```powershell irm https://raw.githubusercontent.com/chargebee/cli/main/install.ps1 | iex ``` +On macOS, Linux, and Windows, accepting the skill installation prompt adds the skill for every detected coding agent. To choose agents yourself, answer No and then run `chargebee skills add --global --agent cursor` (repeat `--agent` for other agents). + **npm** (requires Node.js 22+) ```bash diff --git a/install.ps1 b/install.ps1 index 3dc31b4..b19a37c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -209,10 +209,12 @@ function Read-YesNo([string]$question) { if (Test-OnboardingPrompt) { Write-Host "" - if (Read-YesNo "Install the Chargebee CLI skill for your coding agent?") { - & $dest skills add --path $env:USERPROFILE + if (Read-YesNo "Install the Chargebee CLI skill for all detected coding agents?") { + # --path treats the home directory as a project and puts Codex skills in + # the wrong scope. The answer above authorizes all detected global targets. + & $dest skills add --global --yes } else { - Write-Host " Later: chargebee skills add" + Write-Host " Later: chargebee skills add --global" } if (Read-YesNo "Add a cb shortcut for the chargebee command?") { # Tell the CLI which profile this PowerShell edition actually loads. @@ -226,7 +228,7 @@ if (Test-OnboardingPrompt) { } else { Write-Host "" Write-Host " Using an AI agent? Install the Chargebee CLI skill:" - Write-Host " chargebee skills add" + Write-Host " chargebee skills add --global" Write-Host " Prefer a shorter command?" Write-Host " chargebee alias set" } diff --git a/src/tests/native/windows-install-terminal.py b/src/tests/native/windows-install-terminal.py index 4d6fac5..b67ea9d 100644 --- a/src/tests/native/windows-install-terminal.py +++ b/src/tests/native/windows-install-terminal.py @@ -49,8 +49,8 @@ def run_case(shell, release, url, answer): "CHARGEBEE_CLI_VERSION": release, "DO_NOT_TRACK": "1", "TERM": "xterm-256color", }) - # Override only the destination of alias writes; the real Read-Host and - # CLI prompts run inside ConPTY, including the nested picker if present. + # Override only the destination of alias writes; real Read-Host prompts + # and the released CLI run inside ConPTY. escaped_profile = str(profile).replace("'", "''") command = ( "$ErrorActionPreference = 'Stop'; " @@ -74,7 +74,7 @@ def read_output(): threading.Thread(target=read_output, daemon=True).start() output = "" - skill_answered = alias_answered = picker_answered = False + skill_answered = alias_answered = False cursor_queries = 0 started = time.monotonic() exited_at = None @@ -96,11 +96,7 @@ def read_output(): assert not list(target_home.rglob("SKILL.md")), "Skill installed before consent" proc.write(answer + "\r") skill_answered = True - if not picker_answered and "Select agents" in visible: - # Baseline behavior: accept the detected agents just as - # a user would, so a picker input hang hits the deadline. - proc.write("\r") - picker_answered = True + assert "Select agents" not in visible, "Installer opened a nested agent picker" if not alias_answered and re.search(r"Add a cb shortcut.*?\[Y/n\]", visible): assert not profile.exists(), "Alias written before consent" proc.write(answer + "\r") @@ -127,7 +123,7 @@ def read_output(): assert all("name: chargebee-cli" in path.read_text(encoding="utf-8") for path in skills) assert "Set-Alias cb" in profile.read_text(encoding="utf-8"), "Alias was not installed" print(f"PASS {Path(shell).name}: answer={answer or 'Enter'}, version={version}, " - f"skills={len(skills)}, picker={picker_answered}, seconds={time.monotonic()-started:.1f}", flush=True) + f"skills={len(skills)}, no nested picker, seconds={time.monotonic()-started:.1f}", flush=True) except Exception: print(plain(output), flush=True) raise