Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 1.0.4-beta.0 — 2026-09-02

### Fixes

- `fp-cloud-cli`'s Click shim survives typer 0.27.2, which moved `Abort` out of its vendored Click. `_click_compat` wrapped all six vendored imports in one `try: … except ImportError: from click import …`, so that single missing name rebound **every** symbol to pip Click — the exact silent failure the module exists to prevent. Typer catches only its own Click's exceptions, so every typed error escaped uncaught: `fp alerts show ghost` exited 1 with an empty stderr instead of 6 with a message, and the same for exits 2, 3, 4 and 5. 105 tests went red on the dependabot bump that first installed 0.27.2. The Click is now chosen once — on whether `typer._click` exists at all — and each symbol imported from that choice, so a name that goes missing raises at import (a CLI that will not start) rather than silently downgrading every error to exit 1. `Abort` alone is resolved from `typer.Abort`, which tracks the move by construction: pip Click's before typer 0.26, the vendored class through 0.27.1, `typer.exceptions.Abort` from 0.27.2 (#771)

### Dependencies

- `fp-cloud-cli`: typer 0.27.1 → 0.27.2, click 8.4.2 → 8.5.0, posthog 7.42.0 → 7.44.2 (#771)

- browserslist pinned to 4.28.8 in `overrides`, closing GHSA-73wf-gq98-2v4g and GHSA-c83g-rgw3-j3cx (both 7.5, both fixed in 4.28.7). They turned `main` red on its own scheduled Supply Chain run rather than on any PR's change — disclosed after this branch's first CI run, the same surface-late mechanism `osv-scanner.toml` documents for chromadb. browserslist is transitive-only (via `@babel/helper-compilation-targets`'s `^4.24.0`), so this is an override pin, not a dependency bump — and not `bun update browserslist`, which adds it to `dependencies` as a direct dep it is not and leaves 4.28.2 nested under `@babel/helper-compilation-targets`, keeping the gate red (#771)

## 1.0.3 — 2026-08-31

One user-facing fix, and the three suite fixes that were needed to see it.
Expand Down
13 changes: 9 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 26 additions & 3 deletions fp-cloud-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,32 @@
## 0.0.1b2 — 2026-08-25

Open for the next release. `0.0.1b1` published on 2026-08-24 and the `bump` job
moved the version here automatically; nothing has landed against `0.0.1b2` yet.
Add entries as changes merge — this section becomes the GitHub Release body when
it ships.
moved the version here automatically. Add entries as changes merge — this section
becomes the GitHub Release body when it ships.

### Fixes

- **Typed errors keep their exit codes under typer 0.27.2.** That release moved
`Abort` out of its vendored `typer._click.exceptions`, and `_click_compat`
imported all six of its symbols under a single
`try: … except ImportError: from click import …`. One missing name was enough
to send the whole block to the fallback, binding `ClickException`,
`UsageError`, `BadParameter`, `Command` and `Parameter` to the pip `click`
distribution — which is *not* the Click Typer runs, and Typer catches only its
own. So every typed error escaped its handler: `fp alerts show ghost` exited
**1 with an empty stderr** instead of **6** with `no alert named "ghost"`, and
likewise for exits 2, 3, 4 and 5. Nothing warned; the CLI imported and every
happy path passed. The Click is now decided **once**, on whether `typer._click`
exists at all, and every symbol imported from that decision, so a future move
fails at import — a CLI that refuses to start — instead of silently flattening
the exit-code contract. `Abort` is resolved from `typer.Abort`, which is by
construction the class `typer.prompt` raises and typer's own `_main` catches on
every version in range. Suite verified green against typer 0.25.1, 0.27.0,
0.27.1 and 0.27.2 (#771)

### Dependencies

- typer 0.27.1 → 0.27.2, click 8.4.2 → 8.5.0, posthog 7.42.0 → 7.44.2 (#771)

## 0.0.1b1 — 2026-08-24

Expand Down
39 changes: 31 additions & 8 deletions fp-cloud-cli/fp_cli/_click_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
should print ``✗ …`` and exit 5 exits **1 with an empty stderr**, and the
``rich_format_error`` hook in ``app.py`` (reached only *after* that catch) never
runs. Same for the ``UsageError``/``BadParameter`` we raise by hand.
* ``Abort`` — ``typer.prompt`` raises its own Click's ``Abort`` on closed stdin, so a
pip-Click ``except click.Abort`` stops matching and the clean "no TTY, pass a slug"
usage error becomes a bare abort.
* ``Abort`` — ``typer.prompt`` raises the ``Abort`` **typer** exports on closed stdin
(0.27.2 moved that class out of the vendored Click entirely), so a pip-Click
``except click.Abort`` stops matching and the clean "no TTY, pass a slug" usage error
becomes a bare abort.
* Options — Typer 0.26+ has no ``Option`` class in its vendored Click *at all*: every
option in a Typer-built tree is a ``typer.core.TyperOption``, subclassing
``Parameter`` directly. ``isinstance(param, click.Option)`` is then quietly always
Expand All @@ -27,14 +28,36 @@

from __future__ import annotations

# `Abort` is resolved from `typer` itself rather than from either Click, because it is
# the one symbol here that is not stably a Click class: typer 0.27.2 moved it out of the
# vendored `typer._click.exceptions` into a plain `typer.exceptions.Abort(RuntimeError)`.
# `typer.Abort` tracks that move — it is `click.Abort` before 0.26, the vendored class
# through 0.27.1, the RuntimeError from 0.27.2 — and it is by construction the class
# `typer.prompt` raises and typer's own `_main` catches, which is the only property
# `select.py`'s `except click.Abort` needs.
from typer import Abort as Abort

# Pick the Click ONCE, on whether typer vendors one at all, then import every symbol
# from that choice. Deciding per symbol — a single `try` around the whole vendored
# import block, falling back to pip `click` on any ImportError — is what shipped
# through 0.27.1 and it failed exactly as silently as this module exists to prevent:
# 0.27.2 removing `typer._click.exceptions.Abort` made that one missing name rebind ALL
# SIX symbols to pip Click, so every typed error escaped Typer's handler as exit 1 with
# an empty stderr. A name that goes missing inside the chosen Click must raise here, at
# import, where it is a CLI that refuses to start rather than one that silently stops
# reporting errors.
try:
# typer >= 0.26. pip `click` may well still be installed — it is simply not the
# Click in play, so binding to it here would reintroduce the whole class of bug.
from typer._click import ClickException, Command, Parameter
from typer._click.exceptions import Abort, BadParameter, UsageError
import typer._click as _typer_click # typer >= 0.26 vendors its own Click
except ImportError: # typer < 0.26 drives the pip `click` distribution directly
_typer_click = None # type: ignore[assignment]

if _typer_click is not None:
# pip `click` may well still be installed — it is simply not the Click in play, so
# binding to it here would reintroduce the whole class of bug.
from typer._click import ClickException, Command, Parameter
from typer._click.exceptions import BadParameter, UsageError
else:
from click import ( # type: ignore[assignment]
Abort,
BadParameter,
ClickException,
Command,
Expand Down
11 changes: 9 additions & 2 deletions fp-cloud-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,22 @@ dependencies = [
# through that — which is what makes both sides of the split work. Import Click from
# there and nowhere else; `tests/test_click_compat.py` enforces it, because every way
# this breaks is silent (typed errors collapse to exit 1 with an empty stderr, and the
# telemetry flag catalog empties). Suite verified against 0.25.1 and 0.27.0.
# telemetry flag catalog empties). Suite verified against 0.25.1, 0.27.0, 0.27.1 and
# 0.27.2.
#
# The split keeps moving INSIDE the vendored tree, so the shim resolves each symbol
# separately: 0.27.2 moved `Abort` out of `typer._click.exceptions` into
# `typer.exceptions`, and the one all-or-nothing `try/except ImportError` that used to
# wrap the vendored import sent every OTHER symbol to pip Click over that single
# missing name — 105 tests red, every typed error at exit 1.
#
# The floor is 0.13, not 0.12, because 0.12 cannot BUILD this app: the global
# `--insecure/--secure` option in `app.py` is an `Optional[bool]` with a
# secondary flag, which 0.12's Click reaches as a non-boolean flag and
# rejects with `TypeError: Secondary flag is not valid for non-boolean flag`.
# That fires while the command tree is constructed, before any parsing, so
# `fp --version`, `fp --help` and every subcommand die identically — an
# installed CLI that cannot run one command. `uv.lock` pins 0.27.1, so
# installed CLI that cannot run one command. `uv.lock` pins 0.27.2, so
# neither CI nor the publish smoke test ever installed the broken part of the
# range. Measured across 0.12.0/0.12.3/0.12.5 (all fail) and 0.13.0 upward
# (all pass).
Expand Down
54 changes: 54 additions & 0 deletions fp-cloud-cli/tests/test_click_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import ast
import pathlib

import pytest
import typer
from typer.testing import CliRunner

Expand Down Expand Up @@ -95,6 +96,59 @@ def test_error_exit_codes_are_distinct_per_class():
assert issubclass(KeyModeUnsupportedError, _click_compat.ClickException)


def test_every_symbol_comes_from_the_click_typer_runs():
"""No symbol may quietly come from pip Click while Typer is running its own.

This is the alarm for a *partial* miss. `_click_compat` used to wrap the whole
vendored import in one `try: … except ImportError: from click import …`, so a single
name disappearing from `typer._click` rebound **all six** symbols to pip Click — and
that is not hypothetical: typer 0.27.2 moved `Abort` out of
`typer._click.exceptions`, and the fallback fired, and 105 tests went red at once
with typed errors collapsing to exit 1. Assert the provenance of each symbol
separately, so the next move is one failure naming one symbol.
"""
typer_click = pytest.importorskip(
"typer._click", reason="typer < 0.26 has no vendored Click; pip click IS the right one"
)
wrong = {
name: obj.__module__
for name in ("ClickException", "UsageError", "BadParameter", "Command", "Parameter")
if not (obj := getattr(_click_compat, name)).__module__.startswith(typer_click.__name__)
}
assert not wrong, f"these came from pip Click, not the Click Typer runs: {wrong}"


def test_abort_is_the_one_typer_raises():
"""`Abort` is the exception NOT to pin to a Click — pin it to Typer.

It is `click.Abort` before typer 0.26, the vendored class through 0.27.1, and a
plain `typer.exceptions.Abort(RuntimeError)` from 0.27.2 on. `typer.Abort` is the
class `typer.prompt` raises and typer's own `_main` catches on every one of those,
which is the only thing `select.py`'s `except click.Abort` needs to be true.
"""
assert _click_compat.Abort is typer.Abort


def test_hand_raised_usage_errors_reach_typers_handler():
"""The other half of the contract: `UsageError`/`BadParameter` we raise ourselves.

`test_typed_errors_reach_typers_handler` covers the `ClickException` subclasses in
`errors.py`; these two are raised directly through the shim (`raise
click.UsageError(...)`) from a dozen call sites, and bound to the wrong Click they
escape uncaught the same way — exit 1, empty stderr, instead of a clean exit 2.
"""
for exc_class in (_click_compat.UsageError, _click_compat.BadParameter):
app = typer.Typer()

@app.command()
def boom() -> None:
raise exc_class("bad input")

result = CliRunner().invoke(app, [])
assert result.exit_code == 2, (exc_class, result.output)
assert "bad input" in result.output


def test_is_option_recognises_typer_options():
"""Typer's vendored Click has no `Option` class, so `isinstance` cannot answer this.

Expand Down
Loading