From ded63512acfb5d6c33a9d4577db52a1a0bde204f Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Thu, 2 Jul 2026 14:06:10 +0900 Subject: [PATCH 1/3] fix(ci): regenerate SDK from public-spec.yaml, add narrowing guard The backend spec repo added a narrowed --scope=public export (public-spec.yaml) that excludes dashboard-only/credential-management endpoints (health, webhooks, api-keys, billing, provider-keys, workspace-aggregates, plus a self-serve billing path carve-out under users) from the customer-facing spec. api-spec.yaml is the backend's full internal-dashboard mock/type source and was never meant to be the surface this SDK self-generates from. Also adds a fail-closed guard (mirrors the shaping check already here): refuses to generate if the fetched spec still contains an internal-only tag or the users/me path carve-out, so a regression upstream can't silently publish internal endpoints into the public SDK. Verified the regex against the backend repo's actual public-spec.yaml (clean) and api-spec.yaml (correctly flags every internal tag). --- .github/workflows/regen.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/regen.yml b/.github/workflows/regen.yml index fc2dce9..3d03fdb 100644 --- a/.github/workflows/regen.yml +++ b/.github/workflows/regen.yml @@ -11,9 +11,11 @@ name: Regenerate SDK # workflow_dispatch -> manual regen PR from a given spec-repo SHA (default: main HEAD) # # Requires a GitHub App (org secrets PIPELINE_APP_ID / PIPELINE_APP_PRIVATE_KEY) and the -# repo secret SPEC_REPO (name of the backend repo that publishes api-spec.yaml). The App -# is installed with Contents read on the spec repo and Contents + Pull-requests write here -# (the default GITHUB_TOKEN would not fire CI on the opened PR). +# repo secret SPEC_REPO (name of the backend repo). Fetches public-spec.yaml -- the +# narrowed customer-facing export; NOT api-spec.yaml, which is the backend's full +# internal-dashboard spec and includes internal-only endpoints never meant for the +# public SDK. The App is installed with Contents read on the spec repo and Contents + +# Pull-requests write here (the default GITHUB_TOKEN would not fire CI on the opened PR). on: repository_dispatch: @@ -99,15 +101,29 @@ jobs: RESOLVED_SHA=$(gh api "repos/${OWNER}/${SPEC_REPO}/commits/${REF}" --jq '.sha') echo "resolved_sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT" mkdir -p fern/openapi - gh api "repos/${OWNER}/${SPEC_REPO}/contents/api-spec.yaml?ref=${RESOLVED_SHA}" \ + # public-spec.yaml is the backend's narrowed customer-facing export -- NOT + # api-spec.yaml, the backend's full internal-dashboard spec, which includes + # internal-only endpoints never meant for the public SDK surface. + gh api "repos/${OWNER}/${SPEC_REPO}/contents/public-spec.yaml?ref=${RESOLVED_SHA}" \ -H "Accept: application/vnd.github.raw" > fern/openapi/openapi.yml - echo "Fetched api-spec.yaml @ ${RESOLVED_SHA} ($(wc -l < fern/openapi/openapi.yml) lines)" + echo "Fetched public-spec.yaml @ ${RESOLVED_SHA} ($(wc -l < fern/openapi/openapi.yml) lines)" # The spec must be SHAPED (x-fern shaping present). A raw spec would silently # produce an unshaped SDK — fail loudly instead. if ! grep -q 'x-fern-sdk-group-name' fern/openapi/openapi.yml; then echo "::error::Fetched spec carries no x-fern shaping — backend SDK-shaping export missing at ${RESOLVED_SHA}." exit 1 fi + # The spec must be NARROWED, not just shaped -- this is the whole point of + # fetching public-spec.yaml instead of api-spec.yaml. A regression upstream (or + # this workflow accidentally repointed at the wrong file/repo) should fail loudly + # here rather than silently publish internal endpoints into the public SDK. Keep + # this list in sync with the backend spec repo's exclusion list if either changes. + INTERNAL_LEAK_RE='^[[:space:]]*-[[:space:]]+(name:[[:space:]]+)?(health|webhooks|api-keys|billing|provider-keys|workspace-aggregates)[[:space:]]*$|^[[:space:]]{2}/api/v1/users/me/(subscription|payment-methods|invoices)(/|:)' + if grep -qE "$INTERNAL_LEAK_RE" fern/openapi/openapi.yml; then + echo "::error::Fetched spec contains an internal-only tag/path — expected public-spec.yaml to exclude these at ${RESOLVED_SHA}." + grep -nE "$INTERNAL_LEAK_RE" fern/openapi/openapi.yml | head -5 + exit 1 + fi - uses: actions/setup-node@v4 with: { node-version: '20' } From 80d2f333c08bec43cc91716428615101ad289a59 Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Thu, 2 Jul 2026 15:12:32 +0900 Subject: [PATCH 2/3] fix(cli)!: remove health/provider-keys/workspace-stats commands These 3 command groups (onepin health, onepin provider-keys, onepin workspace stats) call generated SDK resources for tags that are now excluded from public-spec.yaml (health, provider-keys, workspace-aggregates are dashboard-only per the backend's INTERNAL_TAGS classification). Left as-is, the next real SDK regen would silently drop client.health/client.provider_keys/ client.workspace_aggregates and break these commands at runtime. Removes the hand-rolled onepin/_cli/commands/health.py, the table-driven provider-keys and workspace-stats Cmd entries in _spec.py, and their registration in _registry.py. Updates the version-compat gate's stale onepin-health references (the gate itself fires on any API call, not specifically health -- unaffected functionally), the bundled agent-skill docs (SKILL.md/reference.md), the README's generated CLI reference (regenerated via scripts/gen_cli_docs.py) and hand-written usage examples, and the examples/ directory (quickstart.py's health-probe call, and the now-broken provider_keys.py example, removed). BREAKING CHANGE: `onepin health`, `onepin provider-keys`, and `onepin workspace stats` are removed. Programmatic use of client.health / client.provider_keys / client.workspace_aggregates will also stop working once the next SDK regen lands (tracked separately -- that PR is auto-generated by regen.yml, not this one). Co-Authored-By: Claude Sonnet 5 --- README.md | 41 ----- examples/README.md | 3 +- examples/provider_keys.py | 24 --- examples/quickstart.py | 3 - src/onepin/_cli/_skill/onepin/SKILL.md | 4 +- src/onepin/_cli/_skill/onepin/reference.md | 9 +- src/onepin/_cli/_spec.py | 71 -------- src/onepin/_cli/_update_check.py | 10 +- src/onepin/_cli/commands/_registry.py | 13 +- src/onepin/_cli/commands/health.py | 77 --------- tests/cli/_manifest_snapshot.json | 185 --------------------- tests/cli/test_cli_health.py | 147 ---------------- tests/cli/test_cli_provider_keys.py | 95 ----------- tests/unit/test_dispatch.py | 2 +- tests/unit/test_manifest.py | 2 +- tests/unit/test_registry.py | 2 +- 16 files changed, 14 insertions(+), 674 deletions(-) delete mode 100644 examples/provider_keys.py delete mode 100644 src/onepin/_cli/commands/health.py delete mode 100644 tests/cli/test_cli_health.py delete mode 100644 tests/cli/test_cli_provider_keys.py diff --git a/README.md b/README.md index 3bb74be..e7c26e7 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,6 @@ generated from the live command tree (the same source as `onepin schema`) and ke ## CLI command reference -### health - -- `onepin health live` — Liveness probe. -- `onepin health ready` — Readiness probe. - ### login - `onepin login` — Validate an API key and write it to ~/.onepin/credentials. @@ -113,12 +108,6 @@ generated from the live command tree (the same source as `onepin schema`) and ke - `onepin nodes list` — List available node types. - `onepin nodes show ` — Show a node type's detail (runtime options). -### provider-keys - -- `onepin provider-keys delete ` — Delete a provider key. -- `onepin provider-keys list` — List configured provider keys. -- `onepin provider-keys put ` — Create or replace a provider key. - ### schema - `onepin schema` — Emit the machine-readable JSON manifest of all commands. @@ -208,11 +197,6 @@ generated from the live command tree (the same source as `onepin schema`) and ke - `onepin workspace members remove ` — Remove a member. - `onepin workspace members revoke-invite ` — Revoke a pending invite. - `onepin workspace members set-role ` — Change a member's role. - -#### workspace stats - -- `onepin workspace stats runs` — Workspace run statistics. -- `onepin workspace stats workflows` — Workspace workflow statistics. ### Global flags @@ -332,13 +316,6 @@ onepin workspace members accept See `onepin workspace members --help` for the full list (`invite-role`, `revoke-invite`). -#### workspace stats — aggregate statistics - -```bash -onepin workspace stats runs --from 2026-01-01 --to 2026-02-01 -onepin workspace stats workflows -``` - ### usage — inspect workspace usage and activity ```bash @@ -349,16 +326,6 @@ onepin usage by-language --range 90d See `onepin usage --help` for the full list. -### provider-keys — manage bring-your-own-key credentials - -```bash -onepin provider-keys list -onepin provider-keys put --key '{"api_key": "..."}' -onepin provider-keys delete --yes -``` - -Stored credentials are never echoed back; reads return redacted metadata only. - ### nodes — inspect available workflow node types ```bash @@ -366,13 +333,6 @@ onepin nodes list onepin nodes show ``` -### health — API liveness and readiness probes - -```bash -onepin health live -onepin health ready -``` - ### schema — machine-readable command manifest ```bash @@ -448,7 +408,6 @@ client = OnePinClient(token="op_...") # your API key, used as the bearer token workflows = client.workflows.list() # paginated — iterate items directly voices = client.voices.list() -ready = client.health.readiness() ``` ### Environments diff --git a/examples/README.md b/examples/README.md index 8050356..73610fe 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,11 +11,10 @@ python examples/quickstart.py | File | Shows | |------|-------| -| `quickstart.py` | Construct a client, readiness probe, list workflows | +| `quickstart.py` | Construct a client, list workflows | | `list_workflows.py` | Paginate workflows, fetch one by id | | `async_client.py` | `AsyncOnePinClient` + async pagination | | `error_handling.py` | `ApiError`, retries, timeouts | -| `provider_keys.py` | Bring-your-own provider keys (BYO-key) | By default the client targets **PROD** (`https://api.onepin.ai`). Pass `environment=OnePinClientEnvironment.DEV` or `base_url="https://dev-api.onepin.ai"` to diff --git a/examples/provider_keys.py b/examples/provider_keys.py deleted file mode 100644 index 90b685c..0000000 --- a/examples/provider_keys.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Bring-your-own provider keys (e.g. ElevenLabs). - -Provider keys let the platform call third-party providers with *your* credentials. -""" - -import os - -from onepin import OnePinClient - - -def main() -> None: - client = OnePinClient(token=os.environ["ONEPIN_API_KEY"]) - - print("provider keys:", client.provider_keys.list_provider_keys()) - - # Store or replace a provider key (uncomment and supply a real key): - # client.provider_keys.put_provider_key( - # provider="elevenlabs", - # request={"key": os.environ["ELEVENLABS_API_KEY"]}, - # ) - - -if __name__ == "__main__": - main() diff --git a/examples/quickstart.py b/examples/quickstart.py index 21d7f22..b641651 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -14,9 +14,6 @@ def main() -> None: client = OnePinClient(token=os.environ["ONEPIN_API_KEY"]) - # Health probe — a cheap first call to confirm connectivity. - print("readiness:", client.health.readiness()) - # Workflows in your workspace. `list()` returns a pager you can iterate directly. for workflow in client.workflows.list(): print("workflow:", workflow) diff --git a/src/onepin/_cli/_skill/onepin/SKILL.md b/src/onepin/_cli/_skill/onepin/SKILL.md index 3b23290..2488085 100644 --- a/src/onepin/_cli/_skill/onepin/SKILL.md +++ b/src/onepin/_cli/_skill/onepin/SKILL.md @@ -100,7 +100,7 @@ rather than dumping raw JSON. ## Destructive operations (require explicit confirmation) `workflows delete`, `templates delete`, `uploads delete`, `workspace delete`, -`provider-keys delete`, `workflows runs cancel`, `workspace members remove`, +`workflows runs cancel`, `workspace members remove`, `workspace members revoke-invite` — all irreversible. Procedure: 1. **Resolve the exact target.** Confirm the id/name actually exists (via `--search` or a `show`); @@ -119,4 +119,4 @@ rather than dumping raw JSON. - Report failures to the user as `code: message`, plainly. For the full command catalog and recipes (workflow definitions, uploads, workspace + members, -usage, provider-keys, nodes, health), see [reference.md](reference.md) or run `onepin schema`. +usage, nodes), see [reference.md](reference.md) or run `onepin schema`. diff --git a/src/onepin/_cli/_skill/onepin/reference.md b/src/onepin/_cli/_skill/onepin/reference.md index 497a0b3..7b2f70b 100644 --- a/src/onepin/_cli/_skill/onepin/reference.md +++ b/src/onepin/_cli/_skill/onepin/reference.md @@ -25,21 +25,18 @@ position. | `templates` | `list`, `show`, `create`, `update`, `delete`, `clone`, `favorite`, `unfavorite` | | `voices` | `list`, `show`, `similar`, `favorite`, `unfavorite` | | `uploads` | `create` (presigned S3), `confirm`, `delete` | -| `workspace` | `list`, `show`, `create`, `update`, `delete`, `settings`; subgroups `members`, `stats` | +| `workspace` | `list`, `show`, `create`, `update`, `delete`, `settings`; subgroup `members` | | `workspace members` | `list`, `invite`, `set-role`, `remove`, `accept`, `invite-role`, `revoke-invite` | -| `workspace stats` | `runs`, `workflows` (accept `--from`/`--to` ISO datetimes) | | `usage` | `summary`, `by-language`, `activity` (`--range 30d/60d/90d`) | -| `provider-keys` | `list`, `put `, `delete ` (secrets are redacted on read) | | `nodes` | `list`, `show ` (workflow node types + runtime options) | -| `health` | `live`, `ready` | | `auth` | `login`, `logout`, `whoami` | | `schema` | the JSON manifest (the contract) | ## Destructive commands (need `--yes`; confirm with the user first) `workflows delete` · `workflows runs cancel` · `templates delete` · `uploads delete` · -`workspace delete` · `workspace members remove` · `workspace members revoke-invite` · -`provider-keys delete`. Under `--json` without `--yes` they return `CONFIRMATION_REQUIRED` — that +`workspace delete` · `workspace members remove` · `workspace members revoke-invite`. +Under `--json` without `--yes` they return `CONFIRMATION_REQUIRED` — that means stop and ask, not retry. `templates unfavorite` / `voices unfavorite` are *not* destructive. ## Recipe: build a workflow definition diff --git a/src/onepin/_cli/_spec.py b/src/onepin/_cli/_spec.py index 326ccf2..cf409d3 100644 --- a/src/onepin/_cli/_spec.py +++ b/src/onepin/_cli/_spec.py @@ -709,33 +709,6 @@ def _list_opts(*extra: Opt) -> list[Opt]: unwrap="action", success_msg="Accepted invite.", ), - # --- workspace stats (subgroup) ----------------------------------------------------- - Cmd( - "workspace", - "runs", - "workspace_aggregates.workspace_runs_stats", - "Workspace run statistics.", - subgroup="stats", - options=[ - Opt("--from", "datetime", None, dest="from_", transform="datetime", help="Start (ISO 8601)."), - Opt("--to", "datetime", None, dest="to", transform="datetime", help="End (ISO 8601)."), - _JSON, - ], - unwrap="data", - ), - Cmd( - "workspace", - "workflows", - "workspace_aggregates.workspace_workflows_stats", - "Workspace workflow statistics.", - subgroup="stats", - options=[ - Opt("--from", "datetime", None, dest="from_", transform="datetime", help="Start (ISO 8601)."), - Opt("--to", "datetime", None, dest="to", transform="datetime", help="End (ISO 8601)."), - _JSON, - ], - unwrap="data", - ), # --- usage -------------------------------------------------------------------------- Cmd( "usage", @@ -804,50 +777,6 @@ def _list_opts(*extra: Opt) -> list[Opt]: unwrap="list", columns=[], ), - # --- provider-keys (secret redaction) ----------------------------------------------- - Cmd( - "provider-keys", - "list", - "provider_keys.list_provider_keys", - "List configured provider keys.", - options=[_JSON], - unwrap="data", - redact=True, - ), - Cmd( - "provider-keys", - "put", - "provider_keys.put_provider_key", - "Create or replace a provider key.", - args=[("provider", "Provider name (e.g. elevenlabs).")], - options=[ - Opt( - "--key", - "str", - None, - dest="request", - required=True, - transform="provider_key_request", - help='Credential payload: a raw key string (wrapped as {"api_key": ...}) or inline JSON.', - ), - _JSON, - ], - unwrap="data", - redact=True, - success_msg="Saved provider key for {provider}.", - ), - Cmd( - "provider-keys", - "delete", - "provider_keys.delete_provider_key", - "Delete a provider key.", - args=[("provider", "Provider name.")], - options=[_JSON], - unwrap="data", - redact=True, - success_msg="Deleted provider key for {provider}.", - destructive=True, - ), # --- nodes -------------------------------------------------------------------------- Cmd( "nodes", diff --git a/src/onepin/_cli/_update_check.py b/src/onepin/_cli/_update_check.py index 24f2021..5b39add 100644 --- a/src/onepin/_cli/_update_check.py +++ b/src/onepin/_cli/_update_check.py @@ -132,10 +132,7 @@ def _parse_cache(line: str) -> Optional[tuple[str, str]]: def cached_latest() -> Optional[str]: - """Latest version recorded in the cache (any freshness); ``None`` if unknown. - - Used by ``onepin health`` to show the recommended version without a network call. - """ + """Latest version recorded in the cache (any freshness); ``None`` if unknown.""" parsed = _parse_cache(_read_text(_cache_path())) return parsed[1] if parsed else None @@ -160,9 +157,8 @@ def _resolve_latest(current: str) -> Optional[str]: return fresh latest = _fetch_latest() if latest is None: - # Offline / fetch error: do not cache. A failure must not masquerade as "up to date" - # (which would also mislead `onepin health`), and the next run should retry rather than - # stay silent for the full TTL. + # Offline / fetch error: do not cache. A failure must not masquerade as "up to date", + # and the next run should retry rather than stay silent for the full TTL. return None if is_older(current, latest): _write_cache(f"UPGRADE_AVAILABLE {current} {latest}") diff --git a/src/onepin/_cli/commands/_registry.py b/src/onepin/_cli/commands/_registry.py index 81472e6..cea89d0 100644 --- a/src/onepin/_cli/commands/_registry.py +++ b/src/onepin/_cli/commands/_registry.py @@ -3,7 +3,7 @@ Wires: - ``auth`` (login/logout/whoami) -- existing raw-httpx commands, untouched. - Every table-driven command from :data:`onepin._cli._spec.TABLE`, grouped into sub-Typers - (with nested sub-Typers for ``workflows runs`` and ``workspace members``/``stats``). + (with a nested sub-Typer for ``workflows runs``). - Hand-written composites (``workflows run``, ``uploads create``, run downloads, ``workflows definition-schema``). - The top-level ``schema`` manifest command. @@ -16,7 +16,7 @@ from onepin._cli import _dispatch, _manifest from onepin._cli._spec import TABLE, Cmd from onepin._cli._update_check import upgrade_check -from onepin._cli.commands import auth, composites, health, skill +from onepin._cli.commands import auth, composites, skill # Per-group help text. Groups not listed fall back to a generic header. _GROUP_HELP = { @@ -26,15 +26,12 @@ "uploads": "Manage file uploads (presigned-S3 flow).", "workspace": "Manage workspaces, members, and statistics.", "usage": "Inspect workspace usage and activity.", - "provider-keys": "Manage bring-your-own-key provider credentials.", "nodes": "Inspect available workflow node types.", - "health": "API liveness and readiness probes.", } _SUBGROUP_HELP = { ("workflows", "runs"): "Inspect and control workflow runs.", ("workspace", "members"): "Manage workspace members and invites.", - ("workspace", "stats"): "Workspace aggregate statistics.", } @@ -61,12 +58,6 @@ def register(app: typer.Typer) -> None: skill_app.command(name="uninstall", help="Remove the installed Onepin agent skill.")(skill.uninstall) app.add_typer(skill_app, name="skill", help="Manage the Onepin agent skill for AI coding tools.") - # health (live/ready): hand-written so it can surface SDK/API/recommended/required versions. - health_app = typer.Typer(help=_GROUP_HELP["health"], no_args_is_help=True) - health_app.command(name="live", help="Liveness probe.")(health.live) - health_app.command(name="ready", help="Readiness probe.")(health.ready) - app.add_typer(health_app, name="health", help=_GROUP_HELP["health"]) - # Hidden: the /onepin agent skill runs this to drive the upgrade prompt (not user-facing). app.command(name="upgrade-check", hidden=True)(upgrade_check) diff --git a/src/onepin/_cli/commands/health.py b/src/onepin/_cli/commands/health.py deleted file mode 100644 index 5de948f..0000000 --- a/src/onepin/_cli/commands/health.py +++ /dev/null @@ -1,77 +0,0 @@ -"""``onepin health`` -- liveness/readiness probes plus the version/status surface. - -Beyond the probe status, this reports the installed SDK version, the API's reported version, -the recommended version (latest on PyPI, from the upgrade-check cache -- no network here), and -the required floor (the ``X-OnePin-Required-Version`` response header). Hand-written (rather than -table-driven) so it can blend local, cached, and header-sourced facts into one view. -""" - -from __future__ import annotations - -from typing import Any, Optional - -import typer - -from onepin._cli import __version__ -from onepin._cli._ctx import api_errors, get_client, output_json -from onepin._cli.render import render_json -from onepin._version_gate import required_version_from - -# (info key, human label) in display order. -_FIELDS = [ - ("status", "status"), - ("sdk_version", "SDK version"), - ("api_version", "API version"), - ("recommended_version", "Recommended SDK version"), - ("required_version", "Required SDK version"), -] - - -def _recommended() -> Optional[str]: - """Latest version from the upgrade-check cache (no network); None if never checked.""" - from onepin._cli._update_check import cached_latest - - return cached_latest() - - -def _build_info(data: Any, headers: Any) -> dict[str, Optional[str]]: - body = data if isinstance(data, dict) else {} - status = body.get("status") or "ok" - return { - "status": status, - "sdk_version": __version__, - "api_version": body.get("version"), - "recommended_version": _recommended(), - "required_version": required_version_from(headers), - } - - -def _emit(info: dict[str, Optional[str]], json_on: bool) -> None: - if json_on: - render_json({key: value for key, value in info.items() if value is not None}) - return - for key, label in _FIELDS: - value = info.get(key) - typer.echo(f"{label}: {value if value is not None else 'unknown'}") - - -def live( - json_output_local: bool = typer.Option(False, "--json", help="Emit JSON instead of a table."), -) -> None: - """Liveness probe.""" - json_on = output_json(json_output_local) - with api_errors(json_on): - client = get_client() - response = client.health.with_raw_response.liveness() - _emit(_build_info(response.data, response.headers), json_on) - - -def ready( - json_output_local: bool = typer.Option(False, "--json", help="Emit JSON instead of a table."), -) -> None: - """Readiness probe.""" - json_on = output_json(json_output_local) - with api_errors(json_on): - client = get_client() - response = client.health.with_raw_response.readiness() - _emit(_build_info(response.data, response.headers), json_on) diff --git a/tests/cli/_manifest_snapshot.json b/tests/cli/_manifest_snapshot.json index bdde639..06cdcd9 100644 --- a/tests/cli/_manifest_snapshot.json +++ b/tests/cli/_manifest_snapshot.json @@ -1,43 +1,5 @@ { "commands": [ - { - "args": [], - "destructive": false, - "group": "health", - "name": "live", - "options": [ - { - "default": false, - "flag": "--json", - "help": "Emit JSON instead of a table.", - "required": false, - "type": "bool" - } - ], - "path": [ - "health", - "live" - ] - }, - { - "args": [], - "destructive": false, - "group": "health", - "name": "ready", - "options": [ - { - "default": false, - "flag": "--json", - "help": "Emit JSON instead of a table.", - "required": false, - "type": "bool" - } - ], - "path": [ - "health", - "ready" - ] - }, { "args": [], "destructive": false, @@ -115,85 +77,6 @@ "show" ] }, - { - "args": [ - { - "name": "provider" - } - ], - "destructive": true, - "group": "provider-keys", - "name": "delete", - "options": [ - { - "default": false, - "flag": "--json", - "help": "Emit JSON instead of a table.", - "required": false, - "type": "bool" - }, - { - "default": false, - "flag": "--yes", - "help": "Skip the confirmation prompt.", - "required": false, - "type": "bool" - } - ], - "path": [ - "provider-keys", - "delete" - ] - }, - { - "args": [], - "destructive": false, - "group": "provider-keys", - "name": "list", - "options": [ - { - "default": false, - "flag": "--json", - "help": "Emit JSON instead of a table.", - "required": false, - "type": "bool" - } - ], - "path": [ - "provider-keys", - "list" - ] - }, - { - "args": [ - { - "name": "provider" - } - ], - "destructive": false, - "group": "provider-keys", - "name": "put", - "options": [ - { - "default": null, - "flag": "--key", - "help": "Credential payload: a raw key string (wrapped as {\"api_key\": ...}) or inline JSON.", - "required": true, - "type": "text" - }, - { - "default": false, - "flag": "--json", - "help": "Emit JSON instead of a table.", - "required": false, - "type": "bool" - } - ], - "path": [ - "provider-keys", - "put" - ] - }, { "args": [], "destructive": false, @@ -2097,74 +1980,6 @@ "show" ] }, - { - "args": [], - "destructive": false, - "group": "workspace", - "name": "runs", - "options": [ - { - "default": null, - "flag": "--from", - "help": "Start (ISO 8601).", - "required": false, - "type": "text" - }, - { - "default": null, - "flag": "--to", - "help": "End (ISO 8601).", - "required": false, - "type": "text" - }, - { - "default": false, - "flag": "--json", - "help": "Emit JSON instead of a table.", - "required": false, - "type": "bool" - } - ], - "path": [ - "workspace", - "stats", - "runs" - ] - }, - { - "args": [], - "destructive": false, - "group": "workspace", - "name": "workflows", - "options": [ - { - "default": null, - "flag": "--from", - "help": "Start (ISO 8601).", - "required": false, - "type": "text" - }, - { - "default": null, - "flag": "--to", - "help": "End (ISO 8601).", - "required": false, - "type": "text" - }, - { - "default": false, - "flag": "--json", - "help": "Emit JSON instead of a table.", - "required": false, - "type": "bool" - } - ], - "path": [ - "workspace", - "stats", - "workflows" - ] - }, { "args": [ { diff --git a/tests/cli/test_cli_health.py b/tests/cli/test_cli_health.py deleted file mode 100644 index cc8d5a6..0000000 --- a/tests/cli/test_cli_health.py +++ /dev/null @@ -1,147 +0,0 @@ -"""CLI tests for `onepin health` (version/status surface) and the required-version gate. - -Uses respx so the real Fern request/response path and the version-gate response hook both run. -""" - -from __future__ import annotations - -import json - -import httpx -import pytest -import respx -from typer.testing import CliRunner - -from onepin._cli import _update_check as uc -from onepin._cli.main import app - -runner = CliRunner() -_BASE = "https://api.onepin.ai" - - -def _invoke(argv: list[str]): - return runner.invoke(app, ["--api-key", "op_live_x", "--base-url", _BASE, *argv]) - - -@pytest.fixture(autouse=True) -def _fixed_sdk_version(monkeypatch: pytest.MonkeyPatch) -> None: - # Pin the version shown as "SDK version" so assertions are stable. - monkeypatch.setattr("onepin._cli.commands.health.__version__", "0.6.0") - - -def _seed_recommended(version: str) -> None: - path = uc._cache_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(f"UPGRADE_AVAILABLE 0.6.0 {version}\n", encoding="utf-8") - - -class TestHealthLive: - @respx.mock - def test_human_full_surface(self, tmp_home) -> None: - _seed_recommended("0.9.0") - respx.get(f"{_BASE}/health").mock( - return_value=httpx.Response( - 200, - json={"status": "ok", "version": "0.34.3"}, - headers={"X-OnePin-Required-Version": "0.1.0"}, - ) - ) - result = _invoke(["health", "live"]) - assert result.exit_code == 0, result.output - assert "status: ok" in result.output - assert "SDK version: 0.6.0" in result.output - assert "API version: 0.34.3" in result.output - assert "Recommended SDK version: 0.9.0" in result.output - assert "Required SDK version: 0.1.0" in result.output - - @respx.mock - def test_json(self, tmp_home) -> None: - _seed_recommended("0.9.0") - respx.get(f"{_BASE}/health").mock( - return_value=httpx.Response( - 200, - json={"status": "ok", "version": "0.34.3"}, - headers={"X-OnePin-Required-Version": "0.1.0"}, - ) - ) - result = _invoke(["health", "live", "--json"]) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["status"] == "ok" - assert payload["sdk_version"] == "0.6.0" - assert payload["api_version"] == "0.34.3" - assert payload["recommended_version"] == "0.9.0" - assert payload["required_version"] == "0.1.0" - - @respx.mock - def test_unknown_sources(self, tmp_home) -> None: - # No version field in the body, no required header, no cached recommended. - respx.get(f"{_BASE}/health").mock(return_value=httpx.Response(200, json={})) - result = _invoke(["health", "live"]) - assert result.exit_code == 0, result.output - assert "status: ok" in result.output # synthesized on a 200 - assert "API version: unknown" in result.output - assert "Recommended SDK version: unknown" in result.output - assert "Required SDK version: unknown" in result.output - - -class TestRequiredGate: - @respx.mock - def test_stop_via_header_hook(self, tmp_home) -> None: - # A floor above any real installed version trips the client-side response hook. - respx.get(f"{_BASE}/health").mock( - return_value=httpx.Response(200, json={"status": "ok"}, headers={"X-OnePin-Required-Version": "999.0.0"}) - ) - result = _invoke(["health", "live"]) - assert result.exit_code == 1 - assert "999.0.0" in result.output - assert "pip install --upgrade" in result.output - - @respx.mock - def test_stop_json_envelope(self, tmp_home) -> None: - # --json upgrade failures emit the structured error envelope (UPGRADE_REQUIRED). - respx.get(f"{_BASE}/health").mock( - return_value=httpx.Response(200, json={}, headers={"X-OnePin-Required-Version": "999.0.0"}) - ) - result = runner.invoke(app, ["--api-key", "op_live_x", "--base-url", _BASE, "--json", "health", "live"]) - assert result.exit_code == 1 - assert '"code": "UPGRADE_REQUIRED"' in result.output - - @respx.mock - def test_stop_via_server_426(self, tmp_home) -> None: - respx.get(f"{_BASE}/health").mock( - return_value=httpx.Response( - 426, - json={"error": {"code": "sdk_upgrade_required", "required_version": "9.9.9"}}, - ) - ) - result = _invoke(["health", "live"]) - assert result.exit_code == 1 - assert "9.9.9" in result.output - assert "pip install --upgrade" in result.output - - -class TestHealthReady: - @respx.mock - def test_human(self, tmp_home) -> None: - respx.get(f"{_BASE}/ready").mock(return_value=httpx.Response(200, json={"status": "ok"})) - result = _invoke(["health", "ready"]) - assert result.exit_code == 0, result.output - assert "status: ok" in result.output - assert "SDK version: 0.6.0" in result.output - - -class TestAuthPath426: - @respx.mock - def test_whoami_surfaces_upgrade(self, tmp_home) -> None: - # The raw-httpx auth path must also surface a 426 floor as an upgrade stop. - respx.get(f"{_BASE}/api/v1/auth/whoami").mock( - return_value=httpx.Response( - 426, json={"error": {"code": "sdk_upgrade_required", "required_version": "9.9.9"}} - ) - ) - result = runner.invoke(app, ["--api-key", "op_live_x", "--base-url", _BASE, "whoami"]) - assert result.exit_code == 1 - assert "UPGRADE_REQUIRED" in result.output - assert "9.9.9" in result.output - assert "pip install --upgrade" in result.output diff --git a/tests/cli/test_cli_provider_keys.py b/tests/cli/test_cli_provider_keys.py deleted file mode 100644 index eb94429..0000000 --- a/tests/cli/test_cli_provider_keys.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Secret-redaction tests for provider-keys (defensive masking, no --reveal flag).""" - -from __future__ import annotations - -import datetime as dt - -import pytest -from typer.testing import CliRunner - -from onepin._cli import _dispatch -from onepin._cli.main import app - -runner = CliRunner() -NOW = dt.datetime(2025, 1, 1, tzinfo=dt.timezone.utc) -SECRET = "sk-supersecret-7890" - - -def _meta(): - from onepin.types.meta import Meta - - return Meta(request_id="r1", timestamp=NOW) - - -class _ProviderKeysClient: - """Returns an item carrying a (hypothetical) secret-bearing field to prove masking.""" - - captured_request = None - - class provider_keys: # noqa: N801 - @staticmethod - def put_provider_key(provider, *, request, **kw): - _ProviderKeysClient.captured_request = request - from onepin.types import ApiResponseProviderKeyItemOut, ProviderKeyItemOut - - item = ProviderKeyItemOut( - provider=provider, - credentials_schema={}, - configured=True, - is_valid=True, - validated_at=NOW, - key_preview="****7890", - status="valid", - ) - return ApiResponseProviderKeyItemOut(data=item, meta=_meta()) - - @staticmethod - def list_provider_keys(**kw): - from onepin.types import ApiResponseProviderKeysManifestOut, ProviderKeyItemOut, ProviderKeysManifestOut - - item = ProviderKeyItemOut( - provider="elevenlabs", - credentials_schema={"api_key": SECRET}, - configured=True, - is_valid=True, - validated_at=NOW, - key_preview="****7890", - status="valid", - ) - return ApiResponseProviderKeysManifestOut(data=ProviderKeysManifestOut(providers=[item]), meta=_meta()) - - -@pytest.fixture -def patch(monkeypatch: pytest.MonkeyPatch) -> _ProviderKeysClient: - client = _ProviderKeysClient() - monkeypatch.setattr(_dispatch, "get_client", lambda: client) - return client - - -def _invoke(argv: list[str]): - return runner.invoke(app, ["--api-key", "op_live_x", *argv]) - - -class TestRedaction: - def test_put_does_not_echo_input_key(self, patch, tmp_home) -> None: - result = _invoke(["provider-keys", "put", "elevenlabs", "--key", SECRET, "--json"]) - assert result.exit_code == 0, result.output - assert SECRET not in result.output - # The secret still reached the SDK request body (just never printed). - assert patch.captured_request == {"api_key": SECRET} - - def test_list_masks_secret_field(self, patch, tmp_home) -> None: - """Defensive redaction: credentials_schema.api_key is masked by default. - - The server currently returns only ``key_preview`` (already masked), so this - redaction is forward-compat protection against future API changes. - """ - result = _invoke(["provider-keys", "list", "--json"]) - assert result.exit_code == 0, result.output - assert SECRET not in result.output - assert "****7890" in result.output - - def test_no_reveal_flag_exists(self, tmp_home) -> None: - """--reveal was removed; passing it is a usage error.""" - result = _invoke(["provider-keys", "list", "--reveal", "--json"]) - assert result.exit_code == 2 # Typer usage error diff --git a/tests/unit/test_dispatch.py b/tests/unit/test_dispatch.py index 72d2324..6ddf0c3 100644 --- a/tests/unit/test_dispatch.py +++ b/tests/unit/test_dispatch.py @@ -235,7 +235,7 @@ def test_all_commands_build_and_render_help(self) -> None: ["workflows", "runs", "list", "--help"], ["templates", "create", "--help"], ["voices", "list", "--help"], - ["provider-keys", "put", "--help"], + ["nodes", "list", "--help"], ["workspace", "members", "invite", "--help"], ["usage", "summary", "--help"], ], diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index 5949e47..0baa84a 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -50,7 +50,7 @@ def test_render_markdown_structure() -> None: # A command WITH a positional arg renders the form and a help tail (em dash). assert "- `onepin workflows show ` — " in md # A no-arg leaf command renders without angle brackets. - assert "- `onepin health live`" in md + assert "- `onepin templates list`" in md def test_render_markdown_deterministic() -> None: diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index ea9cec8..0bd2ae2 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -28,7 +28,7 @@ def test_register_is_idempotent_shape() -> None: ctx = click.Context(cli) names = set(cli.list_commands(ctx)) - assert {"login", "logout", "whoami", "schema", "skill", "workflows", "provider-keys", "health"} <= names + assert {"login", "logout", "whoami", "schema", "skill", "workflows", "templates"} <= names def test_skill_group_has_install_path_uninstall() -> None: From c43cae7d1d4a077adddab18a0ad2200dd92758a9 Mon Sep 17 00:00:00 2001 From: kj-podonos Date: Thu, 2 Jul 2026 15:42:11 +0900 Subject: [PATCH 3/3] docs(cli): drop dangling comment referencing removed health command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leftover from the health-command removal — the trailing comment in _spec.py still pointed at commands/health.py, which no longer exists. Co-Authored-By: Claude Sonnet 5 --- src/onepin/_cli/_spec.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/onepin/_cli/_spec.py b/src/onepin/_cli/_spec.py index cf409d3..edcbc93 100644 --- a/src/onepin/_cli/_spec.py +++ b/src/onepin/_cli/_spec.py @@ -796,6 +796,4 @@ def _list_opts(*extra: Opt) -> list[Opt]: options=[_JSON], unwrap="data", ), - # health (live/ready) is hand-written in commands/health.py -- it blends local SDK version, - # the API's reported version, and version-gate headers, which the table model can't express. ]