Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/install-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,34 @@ 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
env:
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() {
Expand All @@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
}
Expand Down
3 changes: 3 additions & 0 deletions src/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
182 changes: 182 additions & 0 deletions src/tests/native/windows-install-terminal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""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 sys
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

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"
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; real Read-Host prompts
# and the released CLI run inside ConPTY.
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 = 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
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")
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)}, no nested picker, 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():
# 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)
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()
Loading