diff --git a/README.md b/README.md index b81ea7f..17f241c 100644 --- a/README.md +++ b/README.md @@ -6,68 +6,34 @@ [![node](https://img.shields.io/node/v/@switchbot/openapi-cli.svg)](https://nodejs.org) [![CI](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml) -**SwitchBot** smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances (TV/AC/fan) via the [SwitchBot Cloud API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI). -Run scenes, stream real-time events over MQTT, and plug AI agents into your home via the built-in MCP server — all from your terminal or shell scripts. +SwitchBot smart home CLI — control devices, run scenes, stream events, and plug AI agents into your home via the built-in MCP server. -- **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) -- **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli) +- **npm:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) +- **Source / issues:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli) - **Releases / changelog:** [GitHub Releases](https://github.com/OpenWonderLabs/switchbot-openapi-cli/releases) -- **Issues / feature requests:** [GitHub Issues](https://github.com/OpenWonderLabs/switchbot-openapi-cli/issues) - -> Looking for the **conversational skill** that drives this CLI from a chat -> agent? A companion skill for third-party agent hosts is maintained in a -> separate repository. -> See [`docs/agent-guide.md`](./docs/agent-guide.md) for the authoritative -> surfaces (MCP, `agent-bootstrap`, `schema export`, `capabilities --json`) -> the skill consumes. --- -## Who is this for? - -Three entry points, same binary — pick the one that matches how you use it: +**Human** — start with [Quick start](#quick-start): colored tables, error hints, shell completion, `switchbot doctor`. +**Script** — start with [Global options](#global-options): `--json`, `--format tsv/yaml/id`, `--fields`, stable exit codes, audit log. +**Agent** — start with [`docs/agent-guide.md`](./docs/agent-guide.md): `mcp serve`, `schema export`, `plan run`, destructive-command guards. -- **Human**: start with this README ([Quick start](#quick-start)). - You get colored tables, helpful error hints, shell completion, and - `switchbot doctor` self-check. -- **Script**: start with [Output modes](#output-modes) and - [Scripting examples](#scripting-examples). - You get `--json`, `--format=tsv/yaml/id`, `--fields`, stable exit codes, - `history replay`, and audit log support. -- **Agent**: start with [`docs/agent-guide.md`](./docs/agent-guide.md). - You get `switchbot mcp serve` (stdio MCP server), `schema export`, - `plan run`, and destructive-command guards. - -Under the hood every surface shares the same catalog, cache, and HMAC client — switching between them costs nothing. +Every surface shares the same catalog, cache, and HMAC client. --- -## Table of contents - -- [Features](#features) · [Supported devices](#supported-devices) · [Requirements](#requirements) · [Installation](#installation) -- [Quick start](#quick-start) · [Codex integration](#codex-integration) -- [Credentials](#credentials) -- [Policy](#policy) · [Rules engine](#rules-engine) -- [Global options](#global-options) -- [Commands](#commands): [auth](#auth--login-and-credential-management) · [config](#config--credential-management) · [devices](#devices--list-status-control) · [scenes](#scenes--run-manual-scenes) · [webhook](#webhook--receive-device-events-over-http) · [events](#events--receive-device-events) · [status-sync](#status-sync--mqttopenclaw-bridge) · [daemon](#daemon--background-rules-engine-process) · [plan](#plan--declarative-batch-operations) · [mcp](#mcp--model-context-protocol-server) · [doctor](#doctor--self-check) · [health](#health--runtime-health-report) · [upgrade-check](#upgrade-check--version-check) · [quota](#quota--api-request-counter) · [history](#history--audit-log) · [catalog](#catalog--device-type-catalog) · [schema](#schema--export-catalog-as-json) · [capabilities](#capabilities--cli-manifest) · [cache](#cache--inspect-and-clear-local-cache) · [reset](#reset--clear-local-data) · [policy cmd](#policy--validate-scaffold-and-migrate-policyyaml) · [completion](#completion--shell-tab-completion) -- [Output modes](#output-modes) · [Cache](#cache) · [Exit codes](#exit-codes--error-codes) · [Environment variables](#environment-variables) -- [Scripting examples](#scripting-examples) · [Development](#development) · [License](#license) +## Installation ---- +```bash +npm install -g @switchbot/openapi-cli +``` -## Features +Requires Node.js ≥ 18 and a SwitchBot account with **Developer Options** enabled. -- 🔌 **Complete API coverage** — every `/v1.1` endpoint (devices, scenes, webhooks) -- 📚 **Built-in catalog** — offline reference for every device type's supported commands, parameter formats, and status fields (no API call needed) -- 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting -- 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI -- 🔍 **Dry-run mode** — preview every mutating request before it hits the API -- 🧪 **Fully tested** — 2500+ Vitest tests, mocked axios, zero network in CI -- ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell +--- ## Supported devices -The built-in catalog covers every device type in the [SwitchBot Cloud API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI). Run `switchbot catalog list` to see the full list including aliases and per-command details. | Category | Devices | @@ -85,97 +51,25 @@ Run `switchbot catalog list` to see the full list including aliases and per-comm | **Other** | Bot · AI Art Frame · Home Climate Panel · Remote | | **IR virtual remotes** _(via Hub)_ | Air Conditioner · TV · Streamer · Set Top Box · DVD · Speaker · Fan · Light · Others | -## Requirements - -- **Node.js ≥ 18** -- A SwitchBot account with **Developer Options** enabled (see [Credentials](#credentials)) - -## Installation - -### From npm (recommended) - -```bash -npm install -g @switchbot/openapi-cli -``` - -This adds the `switchbot` binary to your `$PATH`. - -### From source - -```bash -git clone https://github.com/OpenWonderLabs/switchbot-openapi-cli.git -cd switchbot-openapi-cli -npm install -npm run build -npm link # optional — expose `switchbot` globally -``` - -Verify: - -```bash -switchbot --version -switchbot --help -``` +--- ## Quick start -> **Using Codex?** Skip this section and jump to [Codex integration](#codex-integration) — Codex uses a separate plugin package, not the `--skill-path` link below. - -The fast path (credentials + policy + skill link, with rollback on failure): - ```bash -switchbot install --agent claude-code --skill-path ../switchbot-skill -# or preview first -switchbot install --dry-run +switchbot auth login # browser OAuth — saves to OS keychain +switchbot config set-token # or set credentials manually +switchbot devices list # list all devices +switchbot devices command turnOn +switchbot doctor # self-check ``` -Prefer the manual 4-step walk-through? Here it is: - -```bash -# 1. Save your credentials (one-time) -switchbot config set-token - -# 2. List every device on your account -switchbot devices list - -# 3. Control a device, writing a structured entry to the audit log -switchbot devices command turnOn --audit-log - -# 4. Confirm everything is healthy — network, catalog, credentials, cache. -# Any non-"ok" check prints with a hint; fix those first. -switchbot doctor --json | jq '.checks[] | select(.status!="ok")' -``` - -Adding an AI agent or declarative automation? A few more one-liners -round out the first-day path: - -```bash -# 5. Cold-start snapshot an LLM can read before its first tool call. -switchbot agent-bootstrap --compact | jq '.identity, .devices.total' - -# 6. Scaffold a policy.yaml (aliases, quiet hours, confirmations) and -# validate it. Safe to run — defaults apply if you never edit it. -switchbot policy new -switchbot policy validate - -# 7. Stream real-time device events over MQTT (events land as JSONL). -switchbot events mqtt-tail --max 3 --json - -# 8. Run the OpenClaw status bridge in the background. -switchbot status-sync start --openclaw-model home-agent -``` - -See [Policy](#policy) for the authoring flow, [Rules engine](#rules-engine) -for automations, and [`docs/agent-guide.md`](./docs/agent-guide.md) -for the agent surface. +--- ## Codex integration -Use SwitchBot with [OpenAI Codex CLI](https://github.com/openai/codex) to control your smart home devices through natural-language AI conversations. - -### Quick start — just paste this into Codex +The Codex plugin is self-hosted in this repo (`packages/codex-plugin/`) — no separate npm package required. -Not sure how to run commands? Copy the block below and paste it directly into your Codex chat: +**Paste into Codex chat:** ``` Please set up the SwitchBot integration for me by running: @@ -183,750 +77,208 @@ npx @switchbot/openapi-cli codex setup Then restart Codex and confirm it's working. ``` -Codex will run the setup, walk you through signing in, and let you know when it's ready. - -### For developers - -**Requirements:** [Codex CLI](https://github.com/openai/codex) on `$PATH`, Node.js ≥ 18. - -**One-command bootstrap** (installs CLI + plugin + auth + health check in one shot): +**Or run directly (if CLI is already installed):** ```bash -npx @switchbot/openapi-cli codex setup -``` - -**Manual install** (if you prefer explicit control): - -```bash -npm install -g @switchbot/openapi-cli @switchbot/codex-plugin -switchbot install --agent codex # register-only; package must already be installed +codex plugin marketplace add OpenWonderLabs/switchbot-openapi-cli \ + --sparse packages/codex-plugin --ref main +codex plugin add switchbot@codex-plugin switchbot auth login ``` -**Health check and repair:** - -```bash -switchbot codex doctor # 7-check summary; exits 1 on any failure -switchbot codex repair # re-auth + re-register + re-check -``` - -Both `setup` and `repair` accept `--dry-run`, `--json`, `--yes`, and `--profile` / `--config` (global flags). Run `switchbot codex setup --help` for the full flag list. +--- ## Credentials -The CLI reads credentials in this order (first match wins): - -1. **Environment variables** — `SWITCHBOT_TOKEN` and `SWITCHBOT_SECRET` -2. **OS keychain** — native keychain (macOS Keychain / Windows Credential Manager / libsecret on Linux) when populated via `switchbot auth keychain set` -3. **Config file** — `~/.switchbot/config.json` (written by `config set-token`, mode `0600`) - -### Browser login (recommended) - -The fastest way to get started — sign in with your SwitchBot account in the browser: - -```bash -switchbot auth login # opens browser, saves credentials to OS keychain -switchbot auth login --no-open # print URL instead of auto-opening -switchbot auth login --timeout 60 # custom timeout (default 120s) -``` - -The flow uses OAuth 2.0 via `sp.oauth.switchbot.net`, decrypts the API token/secret from the response, verifies them against the SwitchBot API, and stores the result in the OS keychain. - -### Manual setup - -Alternatively, obtain the token and secret from the SwitchBot mobile app: -**Profile → Preferences → Developer Options → Get Token**. - -```bash -# One-time setup (writes ~/.switchbot/config.json) -switchbot config set-token - -# Or export environment variables (e.g. in CI) -export SWITCHBOT_TOKEN=... -export SWITCHBOT_SECRET=... - -# Confirm which source is active and see the masked secret -switchbot config show -``` - -### OS keychain - -Backends: `security(1)` on macOS, `libsecret` / `secret-tool` on Linux, -Credential Manager (via PowerShell + Win32 `CredReadW`/`CredWriteW`) on -Windows. If no native backend is available, the file backend takes -over transparently so the CLI keeps working. `switchbot doctor` -surfaces which backend is active and warns when file-stored credentials -could be moved into a writable keychain. See [`auth` command](#auth--login-and-credential-management) for usage. - -## Policy - -`policy.yaml` is an optional per-user file that declares preferences -the CLI (and any connected AI agent) should honour: device aliases, -quiet-hours, confirmation overrides, audit-log location, and CLI -profile. The file lives at: - -- Linux / macOS: default policy path resolved by the CLI -- Windows: default policy path resolved by the CLI - -Everything in it is optional — if the file is missing, safe defaults -apply. Scaffold, edit, and validate: - -```bash -switchbot policy new # write a commented starter template -$EDITOR -switchbot policy validate # exit 0 if OK, otherwise line-accurate error -``` - -Why most users want a policy file: it makes name resolution -deterministic. Without it, "turn on the bedroom light" falls through -the CLI's prefix/substring/fuzzy match strategies and can pick the -wrong device when two names collide. A one-line `aliases` entry -removes the ambiguity. - -**Schema version.** The CLI requires **policy v0.2**. If you have an existing -v0.1 file from an earlier release, migrate it first: - -```bash -switchbot policy migrate # in-place upgrade, preserves comments -``` - -The v0.2 schema adds a typed `automation.rules[]` block (triggers, conditions, -throttles, dry-run) used by the rules engine (see -[Rules engine](#rules-engine)). Full field-by-field reference, validation flow, -and error catalogue: [`docs/policy-reference.md`](./docs/policy-reference.md). -Five annotated starter files covering common setups live in -[`examples/policies/`](./examples/policies/). - -### Rules engine - -With a policy.yaml (v0.2) you can declare automations that the CLI -executes for you. Supported triggers: **MQTT** (device events), -**cron** (schedule-driven), and **webhook** (local HTTP POST). -Supported conditions: `time_between` (quiet hours), `device_state` -(live API check with per-tick dedup), `event_count` (rolling-window -counts over per-device history), and `llm` (AI decision — see -below). Every fire is recorded in `~/.switchbot/audit.log`. `rules run` is long-running; use -`daemon start` / `daemon reload` for the managed background mode. - -**Actions** — each rule's `then` array accepts two action types: - -- `type: command` (default, no `type` field required) — sends a device command, e.g. `devices command turnOn` -- `type: notify` — delivers a payload to an external channel after the rule fires: - - `channel: webhook` — HTTP POST to a URL (only `http://` and `https://` schemes are accepted; `rules lint` rejects others) - - `channel: file` — appends a JSONL line to a local file. `to` must be an absolute path; relative or `~`-prefixed paths are rejected by `rules lint` (code `notify-relative-path`) and at runtime - - `channel: openclaw` — HTTP POST to an OpenClaw endpoint (same protocol restriction) - - Optional `template` field supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Nested fields use dot paths, e.g. `{{ event.context.deviceMac }}`; arrays index numerically, e.g. `{{ event.list.0 }}` - -```yaml -then: - - command: devices command AC_001 turnOn - - type: notify - channel: webhook - to: https://your.host/hook - template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' -``` - -**LLM condition** — add an AI judgement step before actions fire: - -```yaml -conditions: - - llm: - prompt: "Is the temperature above normal comfort range?" - provider: auto # auto | openai | anthropic | local - cache_ttl: 5m - budget: - max_calls_per_hour: 20 - max_tokens_per_hour: 100000 # optional rolling 1h token cap - max_cost_per_day_usd: 1.00 # optional rolling 24h USD cap - on_error: pass # fail | pass | skip -``` - -Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` for the cloud providers. -For `provider: local`, point `SWITCHBOT_LOCAL_LLM_URL` at any -OpenAI-compatible `/v1/chat/completions` endpoint (Ollama, llama.cpp, -vLLM, LM Studio); `SWITCHBOT_LOCAL_LLM_MODEL` picks the model and -`SWITCHBOT_LOCAL_LLM_TOOL_USE=1` opts into native tool-use when the -endpoint supports it (otherwise a structured-output fallback is used). -`rules lint` flags misconfigured LLM conditions. - -**Decision trace** — set `automation.audit.evaluate_trace: sampled` (or `full`) in `policy.yaml` to record every evaluation decision. +Priority: env vars → OS keychain → `~/.switchbot/config.json` ```bash -switchbot rules lint # static check: exit 0 valid, 1 error -switchbot rules list --json | jq . # structured rule summary -switchbot rules explain "motion on" # trigger, conditions, actions, last fired -switchbot rules run --dry-run --max-firings 5 # run engine; --dry-run = audit only -switchbot daemon reload # hot-reload policy without restart - -switchbot rules tail --follow # stream rule-* audit lines -switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors -switchbot rules summary # aggregate fires/errors (24h) -switchbot rules conflicts # opposing actions, destructive cmds, quiet-hours gaps -switchbot rules doctor --json # lint + conflicts; exit 0 when clean - -switchbot rules suggest --intent "turn off AC at 11pm" -switchbot rules suggest --intent "..." --llm auto # LLM-backed (OPENAI_API_KEY or ANTHROPIC_API_KEY) - -switchbot rules trace-explain --rule "motion on" --last # why a rule fired/was blocked -switchbot rules simulate "motion on" --since 7d --json # replay without running the engine +switchbot auth login # browser OAuth (recommended) +switchbot config set-token # manual setup +export SWITCHBOT_TOKEN=... SWITCHBOT_SECRET=... # CI / env override +switchbot auth keychain set/get/delete # OS keychain management ``` -LLM-generated rules always have `dry_run: true` — flip it yourself after review. Notify URLs must be `http://` or `https://`. - -## Global options - -- `--json`: Print the raw JSON response instead of a formatted table. -- `--format `: Output format: `tsv`, `yaml`, `jsonl`, `json`, `id`. -- `--fields `: Comma-separated column names to include (for example `deviceId,type`). -- `-v`, `--verbose`: Log HTTP request/response details to stderr. -- `--dry-run`: Print mutating requests (POST/PUT/DELETE) without sending them. -- `--timeout `: HTTP request timeout in milliseconds (default `30000`). -- `--config `: Override credential file location (default `~/.switchbot/config.json`). -- `--profile `: Use a named credential profile (`~/.switchbot/profiles/.json`). -- `--cache `: Set list and status cache TTL, for example `5m`, `1h`, `off`, `auto` (default). -- `--cache-list `: Set list-cache TTL independently (overrides `--cache`). -- `--cache-status `: Set status-cache TTL independently (default off; overrides `--cache`). -- `--no-cache`: Disable all cache reads for this invocation. -- `--retry-on-429 `: Max 429 retry attempts (default `3`). -- `--no-retry`: Disable automatic 429 retries. -- `--backoff `: Retry backoff: `exponential` (default) or `linear`. -- `--no-quota`: Disable local request-quota tracking. -- `--audit-log`: Append mutating commands to a JSONL audit log (default path `~/.switchbot/audit.log`). -- `--audit-log-path `: Custom audit log path; use together with `--audit-log`. -- `-V`, `--version`: Print the CLI version. -- `-h`, `--help`: Show help for any command or subcommand. - -Every subcommand supports `--help`. Use `--flag=value` form when a flag takes a value and is followed by a subcommand (e.g. `switchbot --profile=home devices list`). - -### `--dry-run` - -Intercepts every non-GET request: prints the URL/body it would have sent, then exits `0`. GET requests still execute. Also validates command names against the device catalog (exit 2 on unknown commands or read-only sensors). - -```bash -switchbot devices command ABC123 turnOn --dry-run -# [dry-run] Would POST https://api.switch-bot.com/v1.1/devices/ABC123/commands -# [dry-run] body: {"command":"turnOn","parameter":"default","commandType":"command"} -``` +--- ## Commands -### `auth` — login and credential management - -```bash -# Browser-based OAuth login (recommended for first-time setup) -switchbot auth login # opens browser, saves to OS keychain -switchbot auth login --no-open # print URL instead of auto-opening -switchbot auth login --timeout 60 # custom timeout (default 120s) - -# OS keychain management -switchbot auth keychain describe # show active backend -switchbot auth keychain set # write credentials directly -switchbot auth keychain get # verify credentials exist -switchbot auth keychain migrate # move config.json into keychain -switchbot auth keychain delete # remove stored credentials -``` - -### `config` — credential management - -```bash -switchbot config set-token # Save to ~/.switchbot/config.json -switchbot config show # Print current source + masked secret -switchbot config list-profiles # List saved profiles -switchbot config agent-profile --write # write recommended AI-agent profile (mode 0600) -``` - -### `devices` — list, status, control +### `devices` ```bash -# List all physical devices and IR remote devices -switchbot devices list # default 4 columns: deviceId, deviceName, type, category -switchbot devices list --wide # full 10-column operator view -switchbot devices list --json | jq '.deviceList[].deviceId' -switchbot devices list --format=tsv --fields=deviceId,type,category - -# Filter by type / name / category / room -# Operators: = (substring; exact for category), ~ (substring), =/regex/; clauses AND-ed -switchbot devices list --filter 'type=Bot' -switchbot devices list --filter 'name~living,type=/Bulb|Strip/' -switchbot devices list --filter 'category=physical' - -# Query real-time status -switchbot devices status -switchbot devices status --ids ABC,DEF,GHI # batch status -switchbot devices status --ids ABC,DEF --fields power,battery --format jsonl - -# Resolve device by fuzzy name instead of ID (status, command, describe, expand, watch) -switchbot devices status --name "Living Room AC" -switchbot devices command --name "Office Light" turnOn - -# Send a control command -switchbot devices command [parameter] [--type command|customize] - -# Offline reference (no API call) -switchbot devices types # all device types -switchbot devices commands # commands, parameter formats, status fields -``` - -Parameters for `setAll`, `setPosition`, `setMode`, `setBrightness`, and `setColor` are validated client-side (exit 2 on bad input). `setColor` accepts `R:G:B`, `#RRGGBB`, `#RGB`, and CSS names — all normalize to `R:G:B`. Pass `--skip-param-validation` to bypass. Unknown deviceIds exit 2 by default; pass `--allow-unknown-device` for scripted pass-through. - -For per-device command and parameter details: `switchbot devices commands ` or the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands). - -#### `devices expand` — named flags for packed parameters - -Some commands require a packed string like `"26,2,2,on"`. `devices expand` builds it from readable flags: - -```bash -# Air Conditioner — setAll -switchbot devices expand setAll --temp 26 --mode cool --fan low --power on -# Resolve by name -switchbot devices expand --name "Living Room AC" setAll --temp 26 --mode cool --fan low --power on - -# Curtain / Roller Shade — setPosition -switchbot devices expand setPosition --position 50 --mode silent - -# Blind Tilt — setPosition -switchbot devices expand setPosition --direction up --angle 50 - -# Relay Switch — setMode -switchbot devices expand setMode --channel 1 --mode edge - -# Color Bulb / Strip Light / Floor Lamp / Ceiling Light — setBrightness / setColor / setColorTemperature -switchbot devices expand setBrightness --brightness 80 -switchbot devices expand setColor --color "#FF0000" -switchbot devices expand setColorTemperature --color-temp 4000 -``` - -Run `switchbot devices expand --help` to see the available flags for any device command. - -#### `devices explain` — one-shot device summary - -```bash -switchbot devices explain # metadata + commands + live status -switchbot devices explain --no-live # catalog-only, no API call -``` - -#### `devices meta` — local device metadata - -```bash -switchbot devices meta set --alias "Office Light" -switchbot devices meta set --hide # hide from `devices list` -switchbot devices meta get -switchbot devices meta list -switchbot devices meta clear -``` - -Stores local annotations in `~/.switchbot/device-meta.json`. `--show-hidden` on `devices list` reveals hidden devices. - -#### `devices batch` — bulk commands - -```bash -# Same command to every matching device +switchbot devices list [--wide] [--filter 'type=Bot'] [--json] +switchbot devices status [--ids A,B,C] +switchbot devices command [parameter] +switchbot devices expand setAll --temp 26 --mode cool # named flags for packed params +switchbot devices watch [--interval 10s] [--for 5m] switchbot devices batch turnOff --filter 'type=Bot' -switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living' -switchbot devices batch turnOn --ids ID1,ID2,ID3 -switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle - -switchbot devices batch unlock --filter 'type=Smart Lock' --yes # destructive: requires --yes +switchbot devices meta set --alias "Office Light" ``` -Filter keys: `type`, `family`, `room`, `category`. Skipped-offline devices appear under `summary.skipped` when `--skip-offline` is passed. - -### `scenes` — run manual scenes +### `scenes` ```bash -switchbot scenes list # Columns: sceneId, sceneName +switchbot scenes list switchbot scenes execute - -# One-shot summary: risk profile, execution hint, estimated commands -switchbot scenes explain -switchbot scenes explain --json -``` - -### `webhook` — receive device events over HTTP - -```bash -# Register a receiver URL for events from ALL devices -switchbot webhook setup https://your.host/hook - -# Query what is currently configured -switchbot webhook query -switchbot webhook query --details https://your.host/hook - -# Enable / disable / re-submit the registered URL -switchbot webhook update https://your.host/hook --enable -switchbot webhook update https://your.host/hook --disable - -# Remove the configuration -switchbot webhook delete https://your.host/hook -``` - -The CLI validates that `` is an absolute `http://` or `https://` URL before calling the API. `--enable` and `--disable` are mutually exclusive. - -### `events` — receive device events - -#### `events tail` — local webhook receiver - -```bash -switchbot events tail # listen on port 3000 -switchbot events tail --filter deviceId=ABC123 # filter to one device -switchbot events tail --filter 'type=WoMeter' --max 5 --for 10m -switchbot events tail --port 8080 --path /hook --json -``` - -Run `switchbot webhook setup https://your.host/hook` first. `events tail` only runs the local receiver — tunnelling (ngrok/cloudflared) is up to you. - -#### `events mqtt-tail` — real-time MQTT stream - -```bash -switchbot events mqtt-tail # stream all shadow events (Ctrl-C to stop) -switchbot events mqtt-tail --topic 'switchbot/#' # filter to topic subtree -switchbot events mqtt-tail --max 10 --for 30s --json -``` - -Credentials are provisioned automatically from the REST API config. Use `--sink` to route events to external services (`file`, `webhook`, `telegram`, `homeassistant`, `openclaw`) — see `switchbot events mqtt-tail --help` for details. - -### `status-sync` — MQTT/OpenClaw bridge - -Forwards SwitchBot MQTT shadow events into an OpenClaw gateway with stable lifecycle management. - -```bash -switchbot status-sync run --openclaw-model home-agent # foreground (for supervisors) -switchbot status-sync start --openclaw-model home-agent # background -switchbot status-sync status --json -switchbot status-sync stop -``` - -Required: `OPENCLAW_MODEL` (or `--openclaw-model`) and `OPENCLAW_TOKEN`. Optional: `OPENCLAW_URL`, `--topic`, `--state-dir`. Background mode writes `state.json`, `stdout.log`, and `stderr.log` under the state directory. - -### `daemon` — background rules-engine process - -Runs `switchbot rules run` as a detached background process. Tracks runtime -metadata in `~/.switchbot/daemon.state.json` and can co-launch a health HTTP -server. - -```bash -# Start the daemon (no-op if already running) -switchbot daemon start -switchbot daemon start --policy ./my-policy.yaml -switchbot daemon start --healthz-port 3100 # also launch health serve on port 3100 -switchbot daemon start --force # restart even if already running - -# Inspect daemon state (pid, log path, health server, last reload) -switchbot daemon status -switchbot daemon status --json - -# Hot-reload policy without restarting (sends SIGHUP on Unix, writes sentinel on Windows) -switchbot daemon reload - -# Stop the daemon and any co-launched health server -switchbot daemon stop ``` -Start prints the PID, log path, and state file location. If the process exits -within 300 ms of launch, start fails immediately and includes the last 20 lines -of the log in the error message for fast diagnosis. - -### `completion` — shell tab-completion +### `codex` ```bash -# Bash: load on every new shell -echo 'source <(switchbot completion bash)' >> ~/.bashrc - -# Zsh -echo 'source <(switchbot completion zsh)' >> ~/.zshrc - -# Fish -switchbot completion fish > ~/.config/fish/completions/switchbot.fish - -# PowerShell (profile) -switchbot completion powershell >> $PROFILE +switchbot codex setup [--yes] [--dry-run] [--json] # full bootstrap +switchbot codex doctor [--quiet] [--json] # 7-check health summary +switchbot codex repair [--skip re-auth] [--yes] # re-register + re-verify ``` -Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias). - -### `plan` — declarative batch operations +### `auth` ```bash -# Print the plan JSON Schema (give to your agent framework) -switchbot plan schema - -# Draft a candidate plan from natural language intent -switchbot plan suggest --intent "turn off all lights" --device --device - -# Validate a plan file without running it -switchbot plan validate plan.json - -# Preview — mutations skipped, GETs still execute -switchbot --dry-run plan run plan.json - -# Save / review / approve / execute for destructive plans -switchbot plan save plan.json -switchbot plan review -switchbot plan approve -switchbot plan execute -switchbot plan run plan.json --continue-on-error - -# Run with per-step TTY confirmation for destructive steps (human-in-the-loop) -switchbot plan run plan.json --require-approval +switchbot auth login [--no-open] [--timeout 60] +switchbot auth keychain describe/set/get/migrate/delete ``` -A plan file is a JSON document with `version`, `description`, and a `steps` array of `command`, `scene`, or `wait` steps. Steps execute sequentially; a failed step stops the run unless `--continue-on-error` is set. `plan run` is the preview/direct path, but destructive steps are blocked by default and should go through `plan save` → `plan review` → `plan approve` → `plan execute`. See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full schema and agent integration patterns. - -### `devices watch` — poll status +### `config` ```bash -# Poll a device's status every 30 s until Ctrl-C -switchbot devices watch - -# Custom interval; emit every tick even when nothing changed -switchbot devices watch --interval 10s --include-unchanged --json - -# Time-bounded: stop after 5 minutes instead of a fixed tick count -switchbot devices watch --for 5m +switchbot config set-token +switchbot config show +switchbot config list-profiles ``` -Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max ` to stop after N ticks, or `--for ` to stop after an elapsed wall-clock window (e.g. `30s`, `1h`, `2d`). When both are set, whichever limit trips first wins. - -### `mcp` — Model Context Protocol server +### `mcp` ```bash -# Start the stdio MCP server (connect via Claude, Cursor, etc.) -switchbot mcp serve +switchbot mcp serve # stdio MCP server — 24 tools ``` -Exposes MCP tools (`list_devices`, `describe_device`, `get_device_status`, -`get_device_history`, `query_device_history`, `aggregate_device_history`, -`send_command`, `list_scenes`, `run_scene`, `search_catalog`, -`account_overview`, `plan_suggest`, `plan_run`, `audit_query`, -`audit_stats`, `policy_diff`, `policy_validate`, `policy_new`, -`policy_migrate`, `policy_add_rule`, `rules_suggest`, `rule_notifications`, -`rules_explain`, `rules_simulate`) plus a -`switchbot://events` resource for real-time shadow updates. -`rules_suggest` accepts an optional `llm` parameter (`openai | anthropic | auto`) -to generate YAML for complex intents via an LLM backend. -`rule_notifications` returns `rule-notify` audit entries, filterable by rule -name, time range, channel, and result. -`rules_explain` returns the decision trace for a specific evaluation (why a rule -fired or was blocked); `rules_simulate` replays historical events against a rule -and reports would-fire / blocked / throttled outcomes. -See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard). - -### `doctor` — self-check +### `webhook` ```bash -switchbot doctor -switchbot doctor --json +switchbot webhook setup +switchbot webhook query [--details ] +switchbot webhook update --enable/--disable +switchbot webhook delete ``` -Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, catalog-coverage, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, daemon-ipc, health, notify-connectivity, local-llm-reachable, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. `daemon-ipc` round-trips the JSON-RPC socket when the daemon is running (silently skipped otherwise); `local-llm-reachable` only fires when policy uses `provider: local`. Use this to diagnose connectivity or config issues before running automation. - -`--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating: +### `events` ```bash -switchbot doctor --json | jq '{score: .data.maturityScore, label: .data.maturityLabel}' +switchbot events tail [--filter deviceId=X] [--port 8080] +switchbot events mqtt-tail [--max 10] [--for 30s] [--json] ``` -Pass `--fix --yes` to auto-apply safe fixes (e.g. clear stale cache entries) without a prompt. - -### `health` — runtime health report +### `status-sync` ```bash -# One-shot report: quota, audit error rate, circuit-breaker state -switchbot health check -switchbot health check --prometheus # Prometheus text format -switchbot health check --json - -# Start a long-running HTTP server with /healthz and /metrics -switchbot health serve # default port 3100, bind 127.0.0.1 -switchbot health serve --port 8080 -switchbot health serve --json # print {"status":"listening",...} on start +switchbot status-sync start --openclaw-model home-agent +switchbot status-sync status --json +switchbot status-sync stop ``` -`/healthz` returns a JSON health report (HTTP 200 when `ok`/`degraded`, 503 when circuit is open). -`/metrics` returns Prometheus text metrics (`switchbot_quota_used_total`, `switchbot_circuit_open`, …). -Port conflicts are reported immediately with a clear hint to choose a different port via `--port`. +### `rules` / `daemon` -### `upgrade-check` — version check +Policy-driven automations. Triggers: `mqtt` · `cron` · `webhook`. Conditions: `time_between` · `device_state` · `event_count` · `llm`. Actions: `command` · `notify`. ```bash -switchbot upgrade-check # exits 1 when update available -switchbot upgrade-check --json # {current, latest, upToDate, updateAvailable, breakingChange, installCommand} +switchbot rules lint +switchbot rules list/explain/run/simulate +switchbot rules tail/replay/summary/conflicts/doctor +switchbot rules suggest --intent "turn off AC at 11pm" [--llm auto] +switchbot daemon start/stop/reload/status ``` -### `quota` — API request counter - -```bash -switchbot quota status # today's usage + last 7 days (10,000/day limit) -switchbot quota reset -``` +### `plan` -### `history` — audit log +Declarative batch operations. A plan file has `version`, `description`, and a `steps` array. ```bash -switchbot history show --limit 20 -switchbot history replay 7 # re-run entry #7 -switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")' +switchbot plan schema/suggest/validate +switchbot plan run plan.json [--dry-run] [--require-approval] +switchbot plan save/review/approve/execute ``` -### `catalog` — device type catalog +### `policy` ```bash -switchbot catalog show # all built-in types -switchbot catalog show Bot # one type -switchbot catalog search Hub # fuzzy match -switchbot catalog diff # local overlay vs built-in +switchbot policy new/validate/migrate/backup/restore ``` -Create `~/.switchbot/catalog-overlay.json` to extend or override type definitions without modifying the package. - -### `schema` — export catalog as JSON +### `doctor` / `health` ```bash -switchbot schema export # all types -switchbot schema export --type 'Strip Light' -switchbot schema export --role sensor +switchbot doctor [--json] [--fix --yes] +switchbot health check [--json] [--prometheus] +switchbot health serve [--port 3100] ``` -### `capabilities` — CLI manifest +### Other ```bash +switchbot history show [--limit 20] +switchbot quota status/reset +switchbot upgrade-check [--json] +switchbot catalog show/search +switchbot schema export [--type 'Strip Light'] switchbot capabilities --json -switchbot capabilities --used --json # only types seen in the local cache -``` - -Prints a versioned manifest of surfaces, commands, and environment variables. Each command leaf includes `{mutating, consumesQuota, agentSafetyTier, typicalLatencyMs}`. - -### `cache` — inspect and clear local cache - -```bash -switchbot cache show # paths, age, entry counts -switchbot cache clear # clear everything -switchbot cache clear --key list # list cache only -switchbot cache clear --key status # status cache only -``` - -### `reset` — clear local data - -```bash -switchbot reset # interactive confirmation, then wipe everything -switchbot reset --yes # skip confirmation (for scripts) -switchbot reset --keep-credentials # only clear cache/quota/history, keep credentials -``` - -Removes credentials (keychain + config), device cache, quota counter, device history, device metadata, status cache, and audit log. Use `--keep-credentials` to preserve login state while clearing cached data. - -### `policy` — validate, scaffold, and migrate policy.yaml - -```bash -switchbot policy new # write a starter policy -switchbot policy validate # compiler-style errors (line:col + caret) -switchbot policy validate --json | jq '.data.errors' -switchbot policy migrate # upgrade v0.1 → v0.2 in-place -switchbot policy backup # timestamped backup -switchbot policy restore -``` - -Path resolution: positional `[path]` > `SWITCHBOT_POLICY_PATH` > default. Exit codes: `0` valid / `1` invalid / `2` missing / `3` yaml-parse / `4` internal / `5` exists (use `--force`) / `6` unsupported version. - -## Output modes - -- **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details. -- **`--json`** — raw API payload passthrough. Errors are also JSON on **stdout**: `{ "schemaVersion": "1.2", "error": { "code", "kind", "message", "hint?" } }`. -- **`--format=json`** — projected row view; `--fields` applies. -- **`--format=tsv|yaml|jsonl|id`** — tabular text formats. - -```bash -switchbot devices list --json | jq '.deviceList[] | {id: .deviceId, name: .deviceName}' -switchbot devices list --format tsv --fields deviceId,deviceName,type,cloud -switchbot devices list --format id # one deviceId per line +switchbot cache show/clear +switchbot reset [--yes] [--keep-credentials] +switchbot completion bash/zsh/fish/powershell ``` -## Cache - -Two local disk caches under `~/.switchbot/`: +--- -| Cache | Default TTL | Purpose | -|---|---|---| -| `devices.json` | 1 hour | device metadata; powers offline validation | -| `status.json` | off | per-device status; GC'd after 24h | +## Global options -```bash -switchbot devices list --no-cache # bypass for one invocation -switchbot devices status --cache 5m # set list + status TTL -switchbot devices status --cache-list 2h --cache-status 30s -``` +| Flag | Description | +|---|---| +| `--json` | Raw JSON output | +| `--format ` | `tsv` / `yaml` / `jsonl` / `id` | +| `--fields ` | Comma-separated column filter | +| `--dry-run` | Preview mutating requests without sending | +| `--verbose` | Log HTTP request/response to stderr | +| `--timeout ` | HTTP timeout (default `30000`) | +| `--config ` | Override credential file location | +| `--profile ` | Named credential profile | +| `--cache ` | Cache TTL (`5m`, `1h`, `off`, `auto`) | +| `--no-cache` | Disable all cache reads | +| `--retry-on-429 ` | Max 429 retry attempts (default `3`) | +| `--audit-log` | Append mutating commands to audit log | -## Exit codes & error codes +--- -- `0`: Success (including `--dry-run` intercept when validation passes). -- `1`: Runtime error — API error, network failure, missing credentials. -- `2`: Usage error — bad flag, missing/invalid argument, unknown subcommand, - unknown device type, invalid URL, conflicting flags. +## Exit codes -Typical errors bubble up in the form `Error: ` on stderr. The -SwitchBot-specific error codes mapped to readable messages: +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Runtime error (API / network / credentials) | +| `2` | Usage error (bad flag / unknown command / validation) | -- `151`: Device type error. -- `152`: Device not found. -- `160`: Command not supported by this device. -- `161`: Device offline (BLE devices need a Hub). -- `171`: Hub offline. -- `190`: Device internal error / server busy. -- `401`: Authentication failed (check token/secret). -- `429`: Request rate too high (10,000 req/day cap). +--- ## Environment variables -- `SWITCHBOT_TOKEN`: API token — takes priority over the config file. -- `SWITCHBOT_SECRET`: API secret — takes priority over the config file. -- `SWITCHBOT_OAUTH_CLIENT_SECRET`: Override the bundled OAuth client secret (advanced). -- `SWITCHBOT_TOKEN_AES_KEY`: Override the AES-128-CBC key used for token decryption (advanced). -- `SWITCHBOT_TOKEN_AES_IV`: Override the AES-128-CBC IV used for token decryption (advanced). -- `NO_COLOR`: Disable ANSI colors in all output (automatically respected). - -## Scripting examples - -```bash -# Turn off every Bot device -switchbot devices list --json \ - | jq -r '.deviceList[] | select(.deviceType == "Bot") | .deviceId' \ - | while read id; do switchbot devices command "$id" turnOff; done +| Variable | Description | +|---|---| +| `SWITCHBOT_TOKEN` | API token (overrides config file) | +| `SWITCHBOT_SECRET` | API secret (overrides config file) | +| `NO_COLOR` | Disable ANSI colors | +| `CODEX_GIT_MARKETPLACE_REF` | Git ref used when registering the Codex plugin via the git marketplace (default: `main`) | -# Dump each scene as ` ` -switchbot scenes list --json | jq -r '.[] | "\(.sceneId) \(.sceneName)"' -``` +--- ## Development ```bash -git clone https://github.com/OpenWonderLabs/switchbot-openapi-cli.git -cd switchbot-openapi-cli -npm install - -npm run dev -- # Run from TypeScript sources via tsx -npm run build # Compile to dist/ -npm test # Run the Vitest suite -npm run test:watch # Watch mode -npm run test:coverage # Coverage report (v8, HTML + text) +npm install && npm run build +npm run dev -- # run from TypeScript via tsx +npm test # Vitest suite ``` -Source layout: `src/commands/` (one file per command group), `src/devices/` (catalog + cache), `src/rules/` (engine, matcher, throttle, audit), `src/policy/` (validate, migrate, schema), `src/llm/` (providers), `src/utils/` (output, format, flags). Tests are in `tests/` and mirror the `src/` structure. - -### Release flow - -```bash -npm version patch # bump + create git tag -git push --follow-tags -# then: GitHub → Releases → Draft → Publish -``` - -See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full CI / publish verification flow. - ## License [MIT](./LICENSE) © chenliuyun -## References +--- -- [SwitchBot API v1.1 documentation](https://github.com/OpenWonderLabs/SwitchBotAPI) -- Base URL: `https://api.switch-bot.com` -- Rate limit: 10,000 requests/day per account +- [SwitchBot API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI) · Base URL: `https://api.switch-bot.com` · Rate limit: 10,000 req/day diff --git a/package-lock.json b/package-lock.json index 26b5d5f..ba57817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.2", + "version": "3.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "3.7.2", + "version": "3.7.3", "license": "MIT", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index 211cf55..062285d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "3.7.2", + "version": "3.7.3", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 5d427f9..00c0c71 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -8,7 +8,7 @@ Codex plugin for SwitchBot smart-home control through the authoritative - A Codex skill at `skills/switchbot/SKILL.md` - An MCP server definition that runs `switchbot mcp serve --tools all` - A best-effort `onInstall` hook that runs non-interactive setup when the CLI is present -- A bootstrap binary: `switchbot-codex-install` +- Legacy helper binaries: `switchbot-codex-auth` and `switchbot-codex-install` ## Requirements @@ -115,16 +115,14 @@ Expected result: ## Uninstall -Remove the plugin entry you installed. Common Codex plugin IDs are: +Remove the plugin entry: ```bash -codex plugin remove switchbot@switchbot-skill codex plugin remove switchbot@codex-plugin ``` -Repo-marketplace installs usually use `switchbot@switchbot-skill`. Package-local -marketplace installs use `switchbot@codex-plugin` (matches the package directory -name). +Older prerelease installs may have used `switchbot@switchbot-skill`; removing +that id is harmless if Codex reports it is not installed. If you installed the npm package globally and also want to remove the helper commands: @@ -138,7 +136,6 @@ npm uninstall -g @switchbot/codex-plugin To remove the plugin, local policy files, and stored login state: ```bash -codex plugin remove switchbot@switchbot-skill codex plugin remove switchbot@codex-plugin switchbot auth logout ``` diff --git a/packages/codex-plugin/bin/install.js b/packages/codex-plugin/bin/install.js index 8f91986..a4f16e4 100644 --- a/packages/codex-plugin/bin/install.js +++ b/packages/codex-plugin/bin/install.js @@ -55,41 +55,64 @@ function computeAliasPath() { } export function resolveMarketplaceSourceRoot(packageRoot, deps = defaultFsDeps) { - if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)) { + // NOTE: This function is FROZEN. The canonical implementation lives in + // src/install/codex-checks.ts. Do NOT sync new changes here. + // The switchbot-codex-install binary is deprecated; use: switchbot codex setup + const needsAlias = process.platform === 'win32' + ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) + : /\/@[^/]+\//.test(packageRoot); + + if (!needsAlias) { return packageRoot; } const aliasRoot = computeAliasPath(); deps.mkdirSync(dirname(aliasRoot), { recursive: true }); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; const stat = deps.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - deps.symlinkSync(packageRoot, aliasRoot, 'junction'); + deps.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } if (stat.isSymbolicLink()) { - const aliasReal = deps.realpathSync(aliasRoot); - const packageReal = deps.realpathSync(packageRoot); - if (aliasReal.toLowerCase() === packageReal.toLowerCase()) { + let aliasReal; + let packageReal; + try { + aliasReal = deps.realpathSync(aliasRoot); + packageReal = deps.realpathSync(packageRoot); + } catch { + // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). + deps.unlinkSync(aliasRoot); + deps.symlinkSync(packageRoot, aliasRoot, linkType); + return aliasRoot; + } + const pathsMatch = process.platform === 'win32' + ? aliasReal.toLowerCase() === packageReal.toLowerCase() + : aliasReal === packageReal; + if (pathsMatch) { return aliasRoot; } deps.unlinkSync(aliasRoot); - deps.symlinkSync(packageRoot, aliasRoot, 'junction'); + deps.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } - throw new Error(`alias path ${aliasRoot} exists and is not a junction; remove it manually and retry`); + const expected = process.platform === 'win32' ? 'junction' : 'symlink'; + throw new Error(`alias path ${aliasRoot} exists and is not a ${expected}; remove it manually and retry`); } function formatCodexFailure(step) { return [ `[switchbot-codex] Codex CLI not found while running ${step}.`, - '[switchbot-codex] Install or open Codex first, then re-run switchbot-codex-install.', + '[switchbot-codex] Install or open Codex first, then run: npx @switchbot/openapi-cli codex setup', ].join('\n'); } -export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { +const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; + +export function makeInstall({ checkCli, runInherit, packageRoot, runAuth, resolveRoot = resolveMarketplaceSourceRoot }) { return async function install() { process.stderr.write( '[switchbot-codex] WARNING: switchbot-codex-install is deprecated.\n' + @@ -108,7 +131,13 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { process.stderr.write(`[switchbot-codex] CLI ${cliCheck.version} detected.\n`); } - const marketplaceRoot = resolveMarketplaceSourceRoot(packageRoot); + let marketplaceRoot; + try { + marketplaceRoot = resolveRoot(packageRoot); + } catch (err) { + process.stderr.write(`[switchbot-codex] Cannot prepare marketplace path: ${err.message}\n`); + return 1; + } process.stderr.write(`[switchbot-codex] Registering plugin at ${marketplaceRoot}...\n`); const marketplaceCode = await runInherit('codex', ['plugin', 'marketplace', 'add', marketplaceRoot]); if (marketplaceCode !== 0) { @@ -121,6 +150,13 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { } const pluginName = resolvePluginIdentifier(packageRoot); + for (const id of [pluginName, ...CODEX_PLUGIN_LEGACY_IDS]) { + process.stderr.write(`[switchbot-codex] Removing stale plugin ${id} if present...\n`); + const removeCode = await runInherit('codex', ['plugin', 'remove', id]); + if (removeCode !== 0) { + process.stderr.write(`[switchbot-codex] Warning: plugin remove exited ${removeCode}; continuing.\n`); + } + } process.stderr.write(`[switchbot-codex] Adding plugin ${pluginName}...\n`); const pluginCode = await runInherit('codex', ['plugin', 'add', pluginName]); if (pluginCode !== 0) { @@ -130,7 +166,7 @@ export function makeInstall({ checkCli, runInherit, packageRoot, runAuth }) { } process.stderr.write( '[switchbot-codex] "codex plugin add" failed — your Codex version may not support it.\n' + - '[switchbot-codex] Fallback: follow the legacy install steps in CODEX_INSTALL.md.\n' + '[switchbot-codex] Fallback: run npx @switchbot/openapi-cli codex setup after updating Codex.\n' ); return pluginCode; } diff --git a/packages/codex-plugin/tests/install.test.js b/packages/codex-plugin/tests/install.test.js index c0c76e8..2c91c59 100644 --- a/packages/codex-plugin/tests/install.test.js +++ b/packages/codex-plugin/tests/install.test.js @@ -39,10 +39,12 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 3); + assert.equal(calls.length, 5); assert.deepEqual(calls[0], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[2], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -57,11 +59,13 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 0); - assert.equal(calls.length, 4); + assert.equal(calls.length, 6); assert.deepEqual(calls[0], { cmd: 'npm', args: ['install', '-g', '@switchbot/openapi-cli@latest'] }); assert.deepEqual(calls[1], { cmd: 'codex', args: ['plugin', 'marketplace', 'add', TEST_ROOT] }); - assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); - assert.deepEqual(calls[3], { cmd: 'switchbot', args: ['doctor'] }); + assert.deepEqual(calls[2], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[3], { cmd: 'codex', args: ['plugin', 'remove', 'switchbot@switchbot-skill'] }); + assert.deepEqual(calls[4], { cmd: 'codex', args: ['plugin', 'add', 'switchbot@codex-plugin'] }); + assert.deepEqual(calls[5], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -105,7 +109,8 @@ describe('makeInstall', () => { const auth = makeRunAuth(0); const spawn = (cmd, args) => { callCount++; - return Promise.resolve(callCount === 2 ? 3 : 0); + // calls: 1=marketplace add, 2=remove current, 3=remove legacy, 4=plugin add + return Promise.resolve(callCount === 4 ? 3 : 0); }; const install = makeInstall({ checkCli: makeOkCliCheck(), @@ -115,7 +120,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 3); - assert.equal(callCount, 2); + assert.equal(callCount, 4); assert.equal(auth.calls.length, 0); }); @@ -130,7 +135,7 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 4); - assert.equal(calls.length, 2); + assert.equal(calls.length, 4); assert.equal(auth.calls.length, 1); }); @@ -149,8 +154,8 @@ describe('makeInstall', () => { }); const code = await install(); assert.equal(code, 5); - assert.equal(calls.length, 3); - assert.deepEqual(calls[2], { cmd: 'switchbot', args: ['doctor'] }); + assert.equal(calls.length, 5); + assert.deepEqual(calls[4], { cmd: 'switchbot', args: ['doctor'] }); assert.equal(auth.calls.length, 1); }); @@ -172,6 +177,69 @@ describe('makeInstall', () => { assert.equal(callCount, 1); assert.equal(auth.calls.length, 0); }); + + it('returns 1 with a prefixed message when resolveMarketplaceSourceRoot throws', async () => { + const auth = makeRunAuth(0); + const { spawn } = makeSpawn(0); + const errChunks = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; }; + + let code; + try { + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + resolveRoot: () => { + throw new Error('alias path /home/user/.switchbot/codex-plugin-marketplace exists and is not a symlink/junction; remove it manually and retry'); + }, + }); + code = await install(); + } finally { + process.stderr.write = origWrite; + } + + assert.equal(code, 1); + const combined = errChunks.join(''); + assert.ok(combined.includes('[switchbot-codex]'), `expected [switchbot-codex] prefix in: ${combined}`); + assert.ok(combined.includes('codex-plugin-marketplace'), `expected alias path in: ${combined}`); + }); + + it('logs a warning and continues when plugin remove exits non-zero', async () => { + let callCount = 0; + const spawn = (cmd, args) => { + callCount++; + // calls: 1=marketplace add, 2=remove current → failure, 3=remove legacy, 4=plugin add, 5=doctor + return Promise.resolve(callCount === 2 ? 1 : 0); + }; + const auth = makeRunAuth(0); + const errChunks = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...rest) => { errChunks.push(String(chunk)); return true; }; + + let code; + try { + const install = makeInstall({ + checkCli: makeOkCliCheck(), + runInherit: spawn, + packageRoot: TEST_ROOT, + runAuth: auth.runAuth, + }); + code = await install(); + } finally { + process.stderr.write = origWrite; + } + + assert.equal(code, 0, 'install should still succeed'); + assert.equal(callCount, 5, 'all five spawn calls should be made'); + const combined = errChunks.join(''); + assert.ok( + combined.includes('Warning') && combined.includes('remove') && combined.includes('exited'), + `expected warning about remove exit code in: ${combined}`, + ); + }); }); describe('resolvePluginIdentifier', () => { diff --git a/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js index ed85fb9..1e43c31 100644 --- a/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js +++ b/packages/codex-plugin/tests/resolve-marketplace-source-root.test.js @@ -81,4 +81,44 @@ describe('resolveMarketplaceSourceRoot', () => { }); assert.throws(() => resolveMarketplaceSourceRoot(SCOPED_ROOT, deps), /exists and is not a junction/); }); + + it('aliases Linux npm @scope package paths', () => { + const savedPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + try { + const target = '/home/me/.npm-global/lib/node_modules/@switchbot/codex-plugin'; + const created = []; + const deps = makeDeps({ + lstatSync: () => null, + mkdirSync: (p) => created.push(['mkdir', p]), + symlinkSync: (from, to, type) => created.push(['symlink', from, to, type]), + }); + const resolved = resolveMarketplaceSourceRoot(target, deps); + assert.match(resolved, /codex-plugin-marketplace$/); + assert.equal(created[1][1], target); + assert.equal(created[1][3], 'dir'); + } finally { + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + } + }); + + it('aliases Linux custom-prefix path with no node_modules segment', () => { + const savedPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + try { + const target = '/home/me/.local/lib/@switchbot/codex-plugin'; + const created = []; + const deps = makeDeps({ + lstatSync: () => null, + mkdirSync: (p) => created.push(['mkdir', p]), + symlinkSync: (from, to, type) => created.push(['symlink', from, to, type]), + }); + const resolved = resolveMarketplaceSourceRoot(target, deps); + assert.match(resolved, /codex-plugin-marketplace$/); + assert.equal(created[1][1], target); + assert.equal(created[1][3], 'dir'); + } finally { + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + } + }); }); diff --git a/scripts/smoke-codex-pack-install.mjs b/scripts/smoke-codex-pack-install.mjs index 924d07d..92a5184 100644 --- a/scripts/smoke-codex-pack-install.mjs +++ b/scripts/smoke-codex-pack-install.mjs @@ -115,7 +115,6 @@ try { for (const expected of [ 'check-codex-cli', 'install-switchbot-cli', - 'install-codex-plugin', 'register-plugin', 'auth', 'doctor-verify', @@ -141,7 +140,7 @@ try { throw new Error(`codex plugin onInstall hook must exit 0; got ${hook.status ?? 1}\nstderr:\n${hook.stderr}`); } - console.log('codex pack-install smoke ok: tarballs install, setup dry-run includes plugin install, hook is non-blocking'); + console.log('codex pack-install smoke ok: tarballs install, setup dry-run has 5 steps, hook is non-blocking'); } finally { for (const tarball of packed) { rmSync(tarball, { force: true }); diff --git a/src/auth/browser-login.ts b/src/auth/browser-login.ts index 0e22d9d..d96d2a6 100644 --- a/src/auth/browser-login.ts +++ b/src/auth/browser-login.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs'; import open from 'open'; import { generateState } from './csrf.js'; import { bindCallbackServer } from './oauth-callback.js'; @@ -38,11 +39,11 @@ export async function browserLogin(options: BrowserLoginOptions = {}): Promise { + if (process.platform === 'linux' && process.env['WSL_DISTRO_NAME'] !== undefined) { + // WSL: open delegates to PowerShell via wsl-utils. Check the default Windows + // mount path; if missing, fall back to printing the URL rather than crashing. + // Known limitation: non-default root= mounts in /etc/wsl.conf are not checked — + // if PS lives elsewhere open() will still be skipped here. + const wslPsPath = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'; + if (!existsSync(wslPsPath)) return false; + } + try { + await open(url); + return true; + } catch { + return false; + } +} + function startCountdown(deadline: number): { stop(): void } { if (!process.stderr.isTTY) return { stop() {} }; diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 643b920..900e666 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -138,7 +138,7 @@ function cleanupMigratedSourceFile(sourceFile: string, parsed: Record { const base = await runDoctorChecks(CODEX_BASE_SECTIONS); @@ -145,18 +145,20 @@ function repairStepRemovePlugin(ctx: RepairContext): RepairOutcome { pluginId = root.ok ? resolvePluginId(root.packageRoot) : 'switchbot@codex-plugin'; ctx.codexPluginId = pluginId; } - const r = spawnSync( - 'codex', ['plugin', 'remove', pluginId], - { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, - ); - if ((r.status ?? 1) !== 0) { - return { step: 'remove-plugin', status: 'failed', message: `exit ${r.status ?? 1} (non-fatal)` }; + for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + const r = spawnSync( + 'codex', ['plugin', 'remove', id], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 15000 }, + ); + if ((r.status ?? 1) !== 0) { + process.stderr.write(`[switchbot] Warning: codex plugin remove "${id}" exited ${r.status ?? 1} (non-fatal)\n`); + } } return { step: 'remove-plugin', status: 'ok' }; } function stepRegisterPluginShared(stepName: string, ctx: { codexPluginId?: string; packageRoot?: string | null }): StepOutcome { - const r = registerCodexPlugin(); + const r = registerCodexPluginAuto(); if (!r.ok) { return { step: stepName, status: 'failed', message: r.error }; } @@ -197,9 +199,17 @@ const REPAIR_STEPS: readonly StepDef[] = [ { name: 'doctor-verify', description: 'Run Codex doctor checks and report health', skippable: false }, ]; +// Step names removed from SETUP_STEPS/REPAIR_STEPS in past releases; silently +// accepted by --skip for backward compatibility instead of exit 2. +const DEPRECATED_SKIP_NAMES = new Set(['install-codex-plugin']); + function validateSkip(stepDefs: readonly StepDef[], skip: Set): { ok: true } | { ok: false; offending: string } { const skippableNames = new Set(stepDefs.filter((s) => s.skippable).map((s) => s.name)); for (const name of skip) { + if (DEPRECATED_SKIP_NAMES.has(name)) { + console.error(`[switchbot] --skip "${name}" is no longer a valid step name and has no effect`); + continue; + } if (!skippableNames.has(name)) { return { ok: false, offending: name }; } @@ -331,12 +341,11 @@ interface SetupContext { type SetupOutcome = StepOutcome; const SETUP_STEPS: readonly StepDef[] = [ - { name: 'check-codex-cli', description: 'Verify codex CLI on PATH', skippable: false }, - { name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing', skippable: true }, - { name: 'install-codex-plugin', description: 'Install @switchbot/codex-plugin if missing', skippable: true }, - { name: 'register-plugin', description: 'Register plugin via shared registerCodexPlugin()', skippable: false }, - { name: 'auth', description: 'Verify credentials; spawn auth login if missing', skippable: true }, - { name: 'doctor-verify', description: 'Run 4 base + 3 Codex checks and report health', skippable: false }, + { name: 'check-codex-cli', description: 'Verify codex CLI on PATH', skippable: false }, + { name: 'install-switchbot-cli', description: 'Install @switchbot/openapi-cli if missing', skippable: true }, + { name: 'register-plugin', description: 'Register plugin (Route B git; npm install + Route A on fallback)', skippable: false }, + { name: 'auth', description: 'Verify credentials; spawn auth login if missing', skippable: true }, + { name: 'doctor-verify', description: 'Run 4 base + 3 Codex checks and report health', skippable: false }, ]; function setupStepCheckCodexCli(): SetupOutcome { @@ -361,13 +370,6 @@ function setupStepInstallSwitchbotCli(): SetupOutcome { ); } -function setupStepInstallCodexPlugin(): SetupOutcome { - return setupStepInstallGlobalPackage( - 'install-codex-plugin', - CODEX_PLUGIN_PACKAGE, - ); -} - function setupStepInstallGlobalPackage(step: string, packageName: string): SetupOutcome { const list = spawnSync( 'npm', ['list', '-g', '--json', '--depth=0', packageName], @@ -396,7 +398,14 @@ function setupStepInstallGlobalPackage(step: string, packageName: string): Setup } function setupStepRegisterPlugin(ctx: SetupContext): SetupOutcome { - return stepRegisterPluginShared('register-plugin', ctx); + const r = registerCodexPluginAuto(); + if (!r.ok) { + return { step: 'register-plugin', status: 'failed', message: r.error }; + } + ctx.codexPluginId = r.pluginId; + ctx.packageRoot = r.packageRoot; + const via = r.packageRoot ? 'local npm (Route A fallback)' : 'git marketplace (Route B)'; + return { step: 'register-plugin', status: 'ok', message: `registered via ${via}` }; } async function setupStepAuth(ctx: SetupContext): Promise { @@ -447,7 +456,6 @@ async function runSetup( let outcome: SetupOutcome; if (step.name === 'check-codex-cli') outcome = setupStepCheckCodexCli(); else if (step.name === 'install-switchbot-cli') outcome = setupStepInstallSwitchbotCli(); - else if (step.name === 'install-codex-plugin') outcome = setupStepInstallCodexPlugin(); else if (step.name === 'register-plugin') outcome = setupStepRegisterPlugin(ctx); else if (step.name === 'auth') outcome = await setupStepAuth(ctx); else outcome = await setupStepDoctorVerify(); @@ -465,8 +473,13 @@ function registerCodexSetupSubcommand(codex: Command): void { codex .command('setup') .description('Bootstrap the Codex integration end-to-end: install packages if missing, register plugin, auth, verify') - .option('--skip ', 'Comma-separated step names to skip (only "install-switchbot-cli", "install-codex-plugin", or "auth" allowed)') + .option('--skip ', 'Comma-separated step names to skip (skippable: "install-switchbot-cli", "auth"; deprecated no-ops: "install-codex-plugin")') .option('--yes', 'Non-interactive mode: do not spawn auth login, fail fast if credentials missing') + .addHelpText('after', ` +Environment variables: + CODEX_GIT_MARKETPLACE_REF Git ref used when registering via git marketplace (default: main) + CODEX_MARKETPLACE_ADD_TIMEOUT Timeout in ms for "codex plugin marketplace add" (default: 60000) +`) .action(async (opts: { skip?: string; yes?: boolean }, command: Command) => { const skip = new Set( (opts.skip ?? '').split(',').map((s) => s.trim()).filter(Boolean), diff --git a/src/commands/install.ts b/src/commands/install.ts index de17f7b..56dba23 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -92,9 +92,9 @@ function printRecipe(ctx: InstallContext): void { break; case 'codex': lines.push( - ' # Prerequisite: npm install -g @switchbot/codex-plugin', - ' # Codex plugin was registered with the Codex CLI.', - ' # To re-register: switchbot install --agent codex', + ' # Recommended full bootstrap: npx @switchbot/openapi-cli codex setup', + ' # This command only re-registers an already-installed Codex plugin.', + ' # To repair an existing setup: switchbot codex repair', ); break; case 'none': diff --git a/src/commands/rules.ts b/src/commands/rules.ts index b662271..01945e1 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -1085,7 +1085,7 @@ function registerSimulate(rules: Command): void { export function registerRulesCommand(program: Command): void { const rules = program .command('rules') - .description('Run, list, and lint automation rules declared in policy.yaml (v0.2, preview).') + .description('Manage automation rules in policy.yaml: author, lint, run (MQTT/cron/webhook), debug, and simulate.') .addHelpText( 'after', ` @@ -1106,6 +1106,8 @@ Subcommands: doctor [path] Combined health check: lint + conflict analysis + summary. summary Aggregate rule-fire counts per rule over a time window. last-fired Show the N most recently fired rule-fire audit entries. + trace-explain [fireId] Show per-condition trace for a rule evaluation (why it fired or was blocked). + simulate Replay historical events against a rule; reports would-fire / blocked outcomes. webhook-rotate-token Rotate the bearer token used for webhook triggers. webhook-show-token Print the current bearer token (creating one if absent). diff --git a/src/install/codex-checks.ts b/src/install/codex-checks.ts index c80ea71..6e3ad76 100644 --- a/src/install/codex-checks.ts +++ b/src/install/codex-checks.ts @@ -19,17 +19,17 @@ export interface RegistrationResult { export interface RegisterCodexPluginResult { ok: boolean; pluginId: string; - packageRoot: string; + packageRoot: string | null; error?: string; exitCode?: number; stderr?: string; } -function spawnStr(cmd: string, args: string[]): { status: number; stdout: string; stderr: string } { +function spawnStr(cmd: string, args: string[], timeout = 10000): { status: number; stdout: string; stderr: string } { const r = spawnSync(cmd, args, { encoding: 'utf-8', shell: process.platform === 'win32', - timeout: 10000, + timeout, }); return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; } @@ -79,31 +79,52 @@ function computeAliasPath(): string { } export function resolveMarketplaceSourceRoot(packageRoot: string): string { - if (process.platform !== 'win32' || !/^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot)) { - return packageRoot; - } + // Codex misclassifies local paths containing `@`-scoped npm segments + // (e.g. `…/node_modules/@switchbot/codex-plugin`) as ref-bearing git sources, + // causing `marketplace add` to fail with "--ref is only supported for git + // marketplace sources". Affects Windows and Linux/macOS alike. Bridge through + // a symlink/junction at a stable `@`-free location. + const needsAlias = process.platform === 'win32' + ? /^[A-Za-z]:[\\/].*[\\/]@[^\\/]+[\\/]/.test(packageRoot) + : /\/@[^/]+\//.test(packageRoot); + + if (!needsAlias) return packageRoot; const aliasRoot = computeAliasPath(); fs.mkdirSync(path.dirname(aliasRoot), { recursive: true }); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + const stat = fs.lstatSync(aliasRoot, { throwIfNoEntry: false }); if (!stat) { - fs.symlinkSync(packageRoot, aliasRoot, 'junction'); + fs.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } if (stat.isSymbolicLink()) { - const aliasReal = fs.realpathSync(aliasRoot); - const packageReal = fs.realpathSync(packageRoot); - if (aliasReal.toLowerCase() === packageReal.toLowerCase()) { + let aliasReal: string; + let packageReal: string; + try { + aliasReal = fs.realpathSync(aliasRoot); + packageReal = fs.realpathSync(packageRoot); + } catch { + // Dangling symlink: target was deleted (e.g. nvm switch, npm uninstall). + // Recreate it pointing at the current packageRoot. + fs.unlinkSync(aliasRoot); + fs.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } + const pathsMatch = process.platform === 'win32' + ? aliasReal.toLowerCase() === packageReal.toLowerCase() + : aliasReal === packageReal; + if (pathsMatch) return aliasRoot; fs.unlinkSync(aliasRoot); - fs.symlinkSync(packageRoot, aliasRoot, 'junction'); + fs.symlinkSync(packageRoot, aliasRoot, linkType); return aliasRoot; } - throw new Error(`alias path ${aliasRoot} exists and is not a junction; remove it manually and retry`); + const expected = process.platform === 'win32' ? 'junction' : 'symlink'; + throw new Error(`alias path ${aliasRoot} exists and is not a ${expected}; remove it manually and retry`); } /** Single authoritative plugin ID resolver. Mirrors install.js:resolvePluginIdentifier. */ @@ -120,7 +141,7 @@ export function checkCodexCli(): Check { status: 'fail', detail: { message: 'codex CLI not found on PATH. Install from https://github.com/openai/codex', - hint: 'Install Codex, then re-run: switchbot install --agent codex', + hint: 'Install Codex, then re-run: npx @switchbot/openapi-cli codex setup', }, }; } @@ -147,7 +168,7 @@ export function checkCodexPluginNpm(): Check { return { name: 'codex-plugin-npm', status: 'warn', - detail: { message: 'not installed — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }, + detail: { message: 'npm fallback package not installed — run: npx @switchbot/openapi-cli codex setup' }, }; } let packageRoot: string | null = null; @@ -200,7 +221,7 @@ export function checkCodexPluginRegistered(): Check { return { name: 'codex-plugin-registered', status: 'warn', - detail: { message: 'switchbot not in codex plugin list — run: npm install -g @switchbot/codex-plugin && switchbot install --agent codex' }, + detail: { message: 'switchbot not in codex plugin list — run: switchbot codex repair' }, }; } if (/switchbot@/i.test(pluginName) && (/\bnot installed\b/i.test(pluginName) || !/\binstalled\b/i.test(pluginName))) { @@ -222,9 +243,10 @@ export function runCodexPluginRegistration(packageRoot: string, pluginId: string if (mkt.status !== 0) { return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; } - // Remove any stale registration first so codex does a fresh install rather than - // an update-with-backup. The backup step hits ACCESS_DENIED on Windows junction paths. - spawnStr('codex', ['plugin', 'remove', pluginId]); + // Remove current and legacy IDs; ignore exit codes (best-effort pre-clean). + for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + spawnStr('codex', ['plugin', 'remove', id]); + } const add = spawnStr('codex', ['plugin', 'add', pluginId]); return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; } @@ -241,14 +263,13 @@ export function resolveCodexPackageRoot(): { ok: true; packageRoot: string } | { } /** - * 共享注册 helper:封装 resolveCodexPackageRoot → resolvePluginId → runCodexPluginRegistration。 - * `install --agent codex`、`codex repair`、`codex setup` 三处注册步骤都通过此函数执行, - * 禁止再各自内联 `npm root -g` 或 pluginId 拼接。 + * Route A fallback: resolve the locally-installed npm package root and register it. + * For new installs, prefer registerCodexPluginGit() (Route B). */ export function registerCodexPlugin(): RegisterCodexPluginResult { const root = resolveCodexPackageRoot(); if (!root.ok) { - return { ok: false, pluginId: '', packageRoot: '', error: root.error }; + return { ok: false, pluginId: '', packageRoot: null, error: root.error }; } const pluginId = resolvePluginId(root.packageRoot); const r = runCodexPluginRegistration(root.packageRoot, pluginId); @@ -264,3 +285,148 @@ export function registerCodexPlugin(): RegisterCodexPluginResult { } return { ok: true, pluginId, packageRoot: root.packageRoot }; } + +// ─── Git-based marketplace registration (Route B) ──────────────────────────── +export const CODEX_GIT_MARKETPLACE_REPO = 'OpenWonderLabs/switchbot-openapi-cli'; +export const CODEX_GIT_MARKETPLACE_SPARSE = 'packages/codex-plugin'; +export const CODEX_GIT_MARKETPLACE_REF = 'main'; +export const CODEX_PLUGIN_DEFAULT_ID = 'switchbot@codex-plugin'; +// Known IDs from pre-release installs; cleaned up by both Route A and Route B. +export const CODEX_PLUGIN_LEGACY_IDS = ['switchbot@switchbot-skill']; + +export function runCodexPluginRegistrationGit(pluginId: string): RegistrationResult { + const ref = process.env['CODEX_GIT_MARKETPLACE_REF'] || CODEX_GIT_MARKETPLACE_REF; + const _envTimeout = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + const _parsedTimeout = Number(_envTimeout ?? ''); + const _timeoutValid = Number.isFinite(_parsedTimeout) && _parsedTimeout > 0; + if (_envTimeout !== undefined && !_timeoutValid) { + process.stderr.write( + `[switchbot] CODEX_MARKETPLACE_ADD_TIMEOUT="${_envTimeout}" is not a valid positive number; using default 60000 ms\n`, + ); + } + const timeout = _timeoutValid ? _parsedTimeout : 60000; + // git clone via marketplace add can take >10 s on slow networks; use 60 s + const mkt = spawnStr('codex', [ + 'plugin', 'marketplace', 'add', + CODEX_GIT_MARKETPLACE_REPO, + '--sparse', CODEX_GIT_MARKETPLACE_SPARSE, + '--ref', ref, + ], timeout); + if (mkt.status !== 0) { + return { ok: false, exitCode: mkt.status, stderr: mkt.stderr, stage: 'marketplace-add' }; + } + // Pre-clean: remove current ID and any known legacy IDs; ignore exit codes + for (const id of [pluginId, ...CODEX_PLUGIN_LEGACY_IDS]) { + spawnStr('codex', ['plugin', 'remove', id]); + } + const add = spawnStr('codex', ['plugin', 'add', pluginId]); + return { ok: add.status === 0, exitCode: add.status, stderr: add.stderr, stage: 'plugin-add' }; +} + +export function registerCodexPluginGit(): RegisterCodexPluginResult { + const pluginId = CODEX_PLUGIN_DEFAULT_ID; + const r = runCodexPluginRegistrationGit(pluginId); + if (!r.ok) { + return { + ok: false, pluginId, packageRoot: null, + error: `${r.stage} exit ${r.exitCode}: ${r.stderr}`, + exitCode: r.exitCode, stderr: r.stderr, + }; + } + return { ok: true, pluginId, packageRoot: null }; +} + +// Install @switchbot/codex-plugin globally if not already present. +// Used by registerCodexPluginAuto as a last resort before retrying Route A. +function installCodexPluginGlobally(): { ok: boolean; installed?: boolean; error?: string } { + const list = spawnSync( + 'npm', ['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, + ); + // Parse JSON regardless of exit code: npm exits 1 on peer-dep warnings even + // when the package is present. Skip the install if the package shows up in the + // dependency tree either way. + try { + const raw = list.stdout ?? ''; + const lines = raw.split('\n'); + const jsonStartIdx = lines.findIndex((l) => l.trimStart().startsWith('{')); + const jsonStr = jsonStartIdx >= 0 ? lines.slice(jsonStartIdx).join('\n') : raw; + const parsed = JSON.parse(jsonStr) as Record; + const deps = (parsed.dependencies ?? {}) as Record; + if (deps['@switchbot/codex-plugin']) return { ok: true, installed: false }; + } catch { /* fall through to install */ } + const install = spawnSync( + 'npm', ['install', '-g', '@switchbot/codex-plugin@latest'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 120000 }, + ); + if ((install.status ?? 1) !== 0) { + return { ok: false, error: `npm install -g failed (exit ${install.status ?? 1}): ${install.stderr ?? ''}` }; + } + // Verify the package now appears in npm list; a mismatch means npm installed + // to a different prefix than the active one (e.g. nvm switching, sudo vs user). + const verify = spawnSync( + 'npm', ['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin'], + { encoding: 'utf-8', shell: process.platform === 'win32', timeout: 10000 }, + ); + if (verify.status === null) { + return { + ok: false, + error: 'post-install npm list timed out; cannot verify @switchbot/codex-plugin was installed correctly', + }; + } + try { + const vRaw = verify.stdout ?? ''; + const vLines = vRaw.split('\n'); + const vJsonIdx = vLines.findIndex((l) => l.trimStart().startsWith('{')); + const vJsonStr = vJsonIdx >= 0 ? vLines.slice(vJsonIdx).join('\n') : vRaw; + const vParsed = JSON.parse(vJsonStr) as Record; + const vDeps = (vParsed.dependencies ?? {}) as Record; + if (!vDeps['@switchbot/codex-plugin']) { + return { + ok: false, + error: 'npm install -g succeeded but @switchbot/codex-plugin not found in npm list (npm prefix mismatch? Run: npm root -g to verify prefix)', + }; + } + } catch { /* verification inconclusive — proceed and let registration catch the error */ } + return { ok: true, installed: true }; +} + +/** + * Try Route B (git marketplace) first; fall back to local npm path if GitHub + * is unreachable or the clone fails. This preserves air-gapped / corporate + * environments where @switchbot/codex-plugin is already installed locally. + */ +export function registerCodexPluginAuto(): RegisterCodexPluginResult { + // Route B: git marketplace — no local npm package required + const git = registerCodexPluginGit(); + if (git.ok) return git; + + // Route A: local npm path (fast path if already installed) + const npm = registerCodexPlugin(); + if (npm.ok) return npm; + + // On-demand install: @switchbot/codex-plugin may not be globally installed yet. + // Covers fresh repair/install scenarios where the npm package is absent. + const install = installCodexPluginGlobally(); + if (!install.ok) { + return { + ok: false, + pluginId: CODEX_PLUGIN_DEFAULT_ID, + packageRoot: null, + error: `Route B failed (${git.error}); Route A failed (${npm.error}); on-demand install failed: ${install.error}. Run: switchbot codex repair`, + }; + } + + // Retry Route A after successful install + const retry = registerCodexPlugin(); + const installPhrase = install.installed + ? 'installed @switchbot/codex-plugin' + : '@switchbot/codex-plugin already present'; + return retry.ok + ? retry + : { + ...retry, + pluginId: CODEX_PLUGIN_DEFAULT_ID, + error: `Route B failed (${git.error}); ${installPhrase} but Route A still failed: ${retry.error}. Run: switchbot codex repair`, + }; +} diff --git a/src/install/default-steps.ts b/src/install/default-steps.ts index 0d9bd9e..ee21be4 100644 --- a/src/install/default-steps.ts +++ b/src/install/default-steps.ts @@ -24,7 +24,7 @@ import { } from '../commands/policy.js'; import { promptTokenAndSecret, readCredentialsFile } from '../commands/config.js'; import { selectCredentialStore, type CredentialStore, type CredentialBundle } from '../credentials/keychain.js'; -import { registerCodexPlugin } from './codex-checks.js'; +import { registerCodexPluginAuto } from './codex-checks.js'; export type AgentName = 'claude-code' | 'cursor' | 'copilot' | 'codex' | 'none'; @@ -340,7 +340,7 @@ export function stepRegisterCodexPlugin(): InstallStep { name: 'register-codex-plugin', description: 'Register @switchbot/codex-plugin with the Codex CLI (marketplace add + plugin add)', async execute(ctx) { - const r = registerCodexPlugin(); + const r = registerCodexPluginAuto(); if (!r.ok) { throw new Error(`Codex plugin registration failed: ${r.error}`); } diff --git a/src/install/preflight.ts b/src/install/preflight.ts index 6f5b9b7..85dcc5f 100644 --- a/src/install/preflight.ts +++ b/src/install/preflight.ts @@ -255,7 +255,7 @@ function checkCodexCliForPreflight(opts: PreflightOptions): PreflightCheck | nul name: 'codex-cli', status: 'fail', message: 'codex CLI not found on PATH', - hint: 'Install Codex (https://github.com/openai/codex), then re-run switchbot install --agent codex', + hint: 'Install Codex (https://github.com/openai/codex), then run: npx @switchbot/openapi-cli codex setup', }; } return { name: 'codex-cli', status: 'ok', message: 'codex CLI found on PATH' }; @@ -274,11 +274,14 @@ function checkCodexPluginForPreflight(opts: PreflightOptions): PreflightCheck | installed = Boolean(parsed.dependencies?.['@switchbot/codex-plugin']); } catch { /* treat as not installed */ } if (!installed) { + // Route B (git marketplace) can register without the npm package, so this + // is not a hard failure — stepRegisterCodexPlugin → registerCodexPluginAuto + // will try git first and fall back to on-demand npm install if needed. return { name: 'codex-plugin-npm', - status: 'fail', - message: '@switchbot/codex-plugin not installed globally', - hint: 'Run: npm install -g @switchbot/codex-plugin (this command only registers an already-installed package)', + status: 'warn', + message: '@switchbot/codex-plugin not installed globally (will be fetched via git marketplace)', + hint: 'Run the full bootstrap instead: npx @switchbot/openapi-cli codex setup', }; } return { name: 'codex-plugin-npm', status: 'ok', message: '@switchbot/codex-plugin installed' }; diff --git a/tests/auth/browser-login.test.ts b/tests/auth/browser-login.test.ts index f0902d2..651beac 100644 --- a/tests/auth/browser-login.test.ts +++ b/tests/auth/browser-login.test.ts @@ -6,6 +6,7 @@ import { SP_OAUTH_LOGIN_URL, OAUTH_CLIENT_ID, OAUTH_SCOPE } from '../../src/auth // ── Mocks ───────────────────────────────────────────────────────────────────── const waitMock = vi.fn(); +const closeMock = vi.fn(); const bindMock = vi.fn(); const exchangeMock = vi.fn(); const openMock = vi.fn(); @@ -38,7 +39,7 @@ function captureLog(): { log: (m: string) => void; lines: string[] } { describe('browserLogin', () => { beforeEach(() => { vi.clearAllMocks(); - bindMock.mockResolvedValue({ port: 53245, wait: waitMock }); + bindMock.mockResolvedValue({ port: 53245, wait: waitMock, close: closeMock }); exchangeMock.mockResolvedValue({ token: 'tok', secret: 'sec' }); }); @@ -101,5 +102,6 @@ describe('browserLogin', () => { await expect(browserLogin({ noOpen: true, log: () => {} })) .rejects.toThrow('Login timed out'); expect(exchangeMock).not.toHaveBeenCalled(); + expect(closeMock).toHaveBeenCalledOnce(); }); }); diff --git a/tests/commands/codex.test.ts b/tests/commands/codex.test.ts index fa06195..612c4a1 100644 --- a/tests/commands/codex.test.ts +++ b/tests/commands/codex.test.ts @@ -23,6 +23,8 @@ vi.mock('../../src/install/codex-checks.js', async (importOriginal) => { checkCodexPluginNpm: checkCodexPluginNpmMock, checkCodexPluginRegistered: checkCodexPluginRegisteredMock, registerCodexPlugin: registerCodexPluginMock, + registerCodexPluginGit: registerCodexPluginMock, + registerCodexPluginAuto: registerCodexPluginMock, }; }); @@ -300,6 +302,92 @@ describe('switchbot codex repair', () => { // default profile → also no --profile in argv expect(argv).not.toContain('--profile'); }); + + it('emits a warning when a deprecated step name is passed to --skip', async () => { + // verify-cli: node+path ok + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // re-auth step runs (not skipped) + spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + registerCodexPluginMock.mockReturnValueOnce({ + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + }); + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stderr } = await runCli( + registerCodexCommand, + ['codex', 'repair', '--skip', 'install-codex-plugin,remove-plugin'], + ); + expect(exitCode).toBe(0); + const errOut = stderr.join('\n'); + expect(errOut).toContain('install-codex-plugin'); + expect(errOut).toMatch(/no.*effect|deprecated|no longer/i); + }); + + it('remove-plugin step also removes legacy ID switchbot@switchbot-skill (Fix 4)', async () => { + // verify-cli passes + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // re-auth: credentials present → no spawn + tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); + // remove-plugin: resolveCodexPackageRoot npm root -g, then remove current id + legacy id + spawnSyncRepairMock + .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) // npm root -g + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove current id + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // remove legacy id + // register-plugin: ok + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + // doctor-verify + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode } = await runCli(registerCodexCommand, ['codex', 'repair', '--skip', 're-auth']); + expect(exitCode).toBe(0); + const removeCalls = spawnSyncRepairMock.mock.calls.filter( + (call) => (call[1] as string[]).includes('remove'), + ); + const removedIds = removeCalls.map((call) => (call[1] as string[])[2]); + expect(removedIds).toContain('switchbot@codex-plugin'); + expect(removedIds).toContain('switchbot@switchbot-skill'); + }); + + it('remove-plugin falls back to default ID "switchbot@codex-plugin" when npm root -g fails', async () => { + // verify-cli passes + runDoctorChecksMock.mockResolvedValueOnce([ + { name: 'node', status: 'ok', detail: 'ok' }, + { name: 'path', status: 'ok', detail: 'ok' }, + ]); + // remove-plugin: npm root -g fails → fallback to 'switchbot@codex-plugin' + spawnSyncRepairMock + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }) // npm root -g fails + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // remove switchbot@codex-plugin + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // remove legacy id + // register-plugin: ok + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + // doctor-verify + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode } = await runCli(registerCodexCommand, ['codex', 'repair', '--skip', 're-auth']); + expect(exitCode).toBe(0); + const removeCalls = spawnSyncRepairMock.mock.calls.filter( + (call) => (call[1] as string[]).includes('remove'), + ); + const removedIds = removeCalls.map((call) => (call[1] as string[])[2]); + expect(removedIds).toContain('switchbot@codex-plugin'); + expect(removedIds).toContain('switchbot@switchbot-skill'); + }); }); // ─── codex setup (C5) ──────────────────────────────────────────────────────── @@ -315,7 +403,7 @@ describe('switchbot codex setup', () => { tryLoadConfigMock.mockReset(); }); - it('--dry-run prints the 6-step list without mutating', async () => { + it('--dry-run prints the 5-step list without mutating', async () => { const { exitCode, stderr } = await runCli( registerCodexCommand, ['codex', 'setup', '--dry-run'], @@ -324,7 +412,7 @@ describe('switchbot codex setup', () => { const out = stderr.join('\n'); expect(out).toContain('check-codex-cli'); expect(out).toContain('install-switchbot-cli'); - expect(out).toContain('install-codex-plugin'); + expect(out).not.toContain('install-codex-plugin'); expect(out).toContain('register-plugin'); expect(out).toContain('auth'); expect(out).toContain('doctor-verify'); @@ -333,7 +421,7 @@ describe('switchbot codex setup', () => { expect(registerCodexPluginMock).not.toHaveBeenCalled(); }); - it('--dry-run --json emits 6 ordered steps with skippable flags', async () => { + it('--dry-run --json emits 5 ordered steps with skippable flags', async () => { const { exitCode, stdout } = await runCli( registerCodexCommand, ['codex', 'setup', '--dry-run', '--json'], @@ -346,13 +434,12 @@ describe('switchbot codex setup', () => { }; const data = parsed.data ?? parsed; expect(data.dryRun).toBe(true); - expect(data.steps).toHaveLength(6); + expect(data.steps).toHaveLength(5); expect(data.steps?.map((s) => s.name)).toEqual([ - 'check-codex-cli', 'install-switchbot-cli', 'install-codex-plugin', 'register-plugin', 'auth', 'doctor-verify', + 'check-codex-cli', 'install-switchbot-cli', 'register-plugin', 'auth', 'doctor-verify', ]); const skippable = Object.fromEntries(data.steps!.map((s) => [s.name, s.skippable])); expect(skippable['install-switchbot-cli']).toBe(true); - expect(skippable['install-codex-plugin']).toBe(true); expect(skippable['auth']).toBe(true); expect(skippable['check-codex-cli']).toBe(false); expect(skippable['register-plugin']).toBe(false); @@ -400,16 +487,9 @@ describe('switchbot codex setup', () => { stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); - // install-codex-plugin step: npm list -g returns the package as already installed - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); - // register-plugin succeeds + // register-plugin: Route B (registerCodexPluginGit) succeeds — no npm install needed registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', - packageRoot: '/usr/local/lib/node_modules/@switchbot/codex-plugin', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // credentials missing tryLoadConfigMock.mockReturnValue(null); @@ -447,14 +527,9 @@ describe('switchbot codex setup', () => { stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); - // install-codex-plugin: already installed - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); + // register-plugin: Route B succeeds registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // credentials missing → spawn auth login tryLoadConfigMock.mockReturnValue(null); @@ -488,14 +563,9 @@ describe('switchbot codex setup', () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); - // install-codex-plugin still runs when only install-switchbot-cli is skipped - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); + // register-plugin: Route B succeeds — no npm install needed registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // credentials present → auth ok without spawn tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); @@ -514,9 +584,8 @@ describe('switchbot codex setup', () => { }; const step = parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli'); expect(step?.status).toBe('skipped'); - // npm list -g was NOT spawned for install-switchbot-cli; the only npm call was the plugin check. - expect(spawnSyncRepairMock).toHaveBeenCalledTimes(1); - expect(spawnSyncRepairMock.mock.calls[0][1]).toContain('@switchbot/codex-plugin'); + // Route B succeeded — no npm calls at all + expect(spawnSyncRepairMock).not.toHaveBeenCalled(); }); it('install-switchbot-cli failure exits 1 (not 2 — only check-codex-cli is preflight)', async () => { @@ -527,15 +596,9 @@ describe('switchbot codex setup', () => { spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); // npm install -g fails spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES' }); - // install-codex-plugin still runs - spawnSyncRepairMock.mockReturnValueOnce({ - status: 0, - stdout: JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '0.1.0' } } }), - stderr: '', - }); - // register-plugin still runs (continues after non-preflight failure) + // register-plugin: Route B succeeds (continues after non-preflight failure) registerCodexPluginMock.mockReturnValueOnce({ - ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', + ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null, }); // auth: credentials present tryLoadConfigMock.mockReturnValue({ token: 't', secret: 's' }); @@ -554,25 +617,24 @@ describe('switchbot codex setup', () => { data?: { preflightFailed: boolean; outcomes: Array<{ step: string; status: string }> }; }; expect(parsed.data!.preflightFailed).toBe(false); - expect(parsed.data!.outcomes).toHaveLength(6); // all 6 steps ran (no preflight halt) + expect(parsed.data!.outcomes).toHaveLength(5); // all 5 steps ran (no preflight halt) expect(parsed.data!.outcomes.find((o) => o.step === 'install-switchbot-cli')?.status).toBe('failed'); // register-plugin still got called despite the earlier failure expect(registerCodexPluginMock).toHaveBeenCalledOnce(); }); - it('installs @switchbot/codex-plugin before registering when missing', async () => { + it('installs @switchbot/codex-plugin on demand when Route B fails', async () => { checkCodexCliMock.mockReturnValueOnce({ name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, }); - // switchbot CLI already installed + // install-switchbot-cli: already installed spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), stderr: '', }); - // codex plugin missing, then install succeeds - spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }); - spawnSyncRepairMock.mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); + // register-plugin: registerCodexPluginAuto handles Route B failure + on-demand install internally. + // Non-null packageRoot signals Route A was used (tested thoroughly in codex-checks.test.ts). registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: '/some/path', }); @@ -590,11 +652,44 @@ describe('switchbot codex setup', () => { const parsed = JSON.parse(stdout.join('')) as { data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; }; - const pluginStep = parsed.data!.outcomes.find((o) => o.step === 'install-codex-plugin'); - expect(pluginStep?.status).toBe('ok'); - expect(pluginStep?.message).toContain('installed @switchbot/codex-plugin@latest'); - expect(spawnSyncRepairMock.mock.calls[1][1]).toEqual(['list', '-g', '--json', '--depth=0', '@switchbot/codex-plugin']); - expect(spawnSyncRepairMock.mock.calls[2][1]).toEqual(['install', '-g', '@switchbot/codex-plugin@latest']); + // No standalone install-codex-plugin step — on-demand install is inside registerCodexPluginAuto + expect(parsed.data!.outcomes.find((o) => o.step === 'install-codex-plugin')).toBeUndefined(); + const registerStep = parsed.data!.outcomes.find((o) => o.step === 'register-plugin'); + expect(registerStep?.status).toBe('ok'); + expect(registerStep?.message).toContain('Route A fallback'); + // registerCodexPluginAuto called once; internal npm calls tested in codex-checks.test.ts expect(registerCodexPluginMock).toHaveBeenCalledOnce(); }); + + it('auth step returns failed when auth login exits non-zero', async () => { + checkCodexCliMock.mockReturnValueOnce({ + name: 'codex-cli', status: 'ok', detail: { path: '/usr/local/bin/codex' }, + }); + // install-switchbot-cli: already installed + spawnSyncRepairMock.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify({ dependencies: { '@switchbot/openapi-cli': { version: '1.0.0' } } }), + stderr: '', + }); + // register-plugin: ok + registerCodexPluginMock.mockReturnValueOnce({ ok: true, pluginId: 'switchbot@codex-plugin', packageRoot: null }); + // credentials missing → spawn auth login + tryLoadConfigMock.mockReturnValue(null); + // auth login exits non-zero + spawnSyncRepairMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'auth failed' }); + // doctor-verify still runs + runDoctorChecksMock.mockResolvedValueOnce(makeBaseChecks()); + checkCodexCliMock.mockReturnValue({ name: 'codex-cli', status: 'ok', detail: 'ok' }); + checkCodexPluginNpmMock.mockReturnValue({ name: 'codex-plugin-npm', status: 'ok', detail: 'ok' }); + checkCodexPluginRegisteredMock.mockReturnValue({ name: 'codex-plugin-registered', status: 'ok', detail: 'ok' }); + + const { exitCode, stdout } = await runCli(registerCodexCommand, ['codex', 'setup', '--json']); + expect(exitCode).toBe(1); // anyFailed but not preflight + const parsed = JSON.parse(stdout.join('')) as { + data?: { outcomes: Array<{ step: string; status: string; message?: string }> }; + }; + const authStep = parsed.data!.outcomes.find((o) => o.step === 'auth'); + expect(authStep?.status).toBe('failed'); + expect(authStep?.message).toContain('auth login exited 1'); + }); }); diff --git a/tests/install/codex-checks.test.ts b/tests/install/codex-checks.test.ts index 006e0e2..ede9be5 100644 --- a/tests/install/codex-checks.test.ts +++ b/tests/install/codex-checks.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { stepRegisterCodexPlugin } from '../../src/install/default-steps.js'; import type { InstallContext } from '../../src/install/default-steps.js'; @@ -36,9 +36,12 @@ import { checkCodexPluginNpm, checkCodexPluginRegistered, runCodexPluginRegistration, + runCodexPluginRegistrationGit, resolveMarketplaceSourceRoot, resolvePluginId, registerCodexPlugin, + registerCodexPluginGit, + registerCodexPluginAuto, } from '../../src/install/codex-checks.js'; function makeSpawnResult(status: number, stdout: string, stderr = ''): ReturnType { @@ -92,9 +95,7 @@ describe('checkCodexPluginNpm', () => { const result = checkCodexPluginNpm(); expect(result.status).toBe('warn'); const msg = String((result.detail as Record).message); - // A4: warning must include the full repair recipe (npm install + switchbot install) - expect(msg).toContain('npm install -g @switchbot/codex-plugin'); - expect(msg).toContain('switchbot install --agent codex'); + expect(msg).toContain('npx @switchbot/openapi-cli codex setup'); }); it('returns warn when npm list json is malformed', () => { @@ -102,6 +103,17 @@ describe('checkCodexPluginNpm', () => { const result = checkCodexPluginNpm(); expect(result.status).toBe('warn'); }); + + it('returns ok with packageRoot null when npm root -g exits non-zero', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, JSON.stringify({ + dependencies: { '@switchbot/codex-plugin': { version: '1.2.3' } }, + }))) + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm error')); // npm root -g fails + const result = checkCodexPluginNpm(); + expect(result.status).toBe('ok'); + expect((result.detail as Record).packageRoot).toBeNull(); + }); }); describe('checkCodexPluginRegistered', () => { @@ -130,9 +142,7 @@ describe('checkCodexPluginRegistered', () => { const result = checkCodexPluginRegistered(); expect(result.status).toBe('warn'); const msg = String((result.detail as Record).message); - // A4: warning must include the full repair recipe (npm install + switchbot install) - expect(msg).toContain('npm install -g @switchbot/codex-plugin'); - expect(msg).toContain('switchbot install --agent codex'); + expect(msg).toContain('switchbot codex repair'); }); it('returns warn with reason codex-cli-missing when codex is not on PATH', () => { @@ -155,7 +165,8 @@ describe('runCodexPluginRegistration', () => { it('returns ok when both marketplace add and plugin add succeed', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(true); @@ -173,7 +184,8 @@ describe('runCodexPluginRegistration', () => { it('returns failure when plugin add exits non-zero', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '')) - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); const result = runCodexPluginRegistration('/some/path', 'switchbot@pkg'); expect(result.ok).toBe(false); @@ -188,7 +200,8 @@ describe('registerCodexPlugin (shared helper)', () => { spawnSyncMock .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add - .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (pre-clean) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add const r = registerCodexPlugin(); expect(r.ok).toBe(true); @@ -204,7 +217,7 @@ describe('registerCodexPlugin (shared helper)', () => { expect(r.ok).toBe(false); expect(r.error).toMatch(/npm root -g failed/); expect(r.pluginId).toBe(''); - expect(r.packageRoot).toBe(''); + expect(r.packageRoot).toBeNull(); }); it('returns failure with normalized error when registration step fails', () => { @@ -301,12 +314,416 @@ describe('resolveMarketplaceSourceRoot', () => { it('throws when the alias path is a real directory', () => { if (process.platform !== 'win32') return; lstatSyncMock.mockReturnValue(makeStat(false)); - expect(() => resolveMarketplaceSourceRoot(SCOPED_ROOT)).toThrow(/exists and is not a junction/); + expect(() => resolveMarketplaceSourceRoot(SCOPED_ROOT)).toThrow(/not a.*junction/i); expect(unlinkSyncMock).not.toHaveBeenCalled(); expect(symlinkSyncMock).not.toHaveBeenCalled(); }); }); +// Codex misclassifies local paths containing `@`-scoped npm segments on all +// platforms, not just Windows. The following tests verify that Linux paths like +// `/home/user/.npm-global/lib/node_modules/@switchbot/codex-plugin` also get +// bridged through an alias symlink so the registered path contains no `@`. +describe('resolveMarketplaceSourceRoot — Linux @-scoped path handling', () => { + const LINUX_SCOPED_ROOT = '/home/user/.npm-global/lib/node_modules/@switchbot/codex-plugin'; + const LINUX_PLAIN_ROOT = '/home/user/.npm-global/lib/node_modules/switchbot-plugin'; + + const savedPlatform = process.platform; + function makeStat(isSymlink: boolean) { + return { isSymbolicLink: () => isSymlink } as unknown as ReturnType; + } + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + }); + afterEach(() => { + Object.defineProperty(process, 'platform', { value: savedPlatform, configurable: true }); + }); + + it('returns plain Linux path unchanged (no @-scoped segment)', () => { + const result = resolveMarketplaceSourceRoot(LINUX_PLAIN_ROOT); + expect(result).toBe(LINUX_PLAIN_ROOT); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + }); + + it('creates a symlink when alias is missing', () => { + lstatSyncMock.mockReturnValue(null); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(mkdirSyncMock).toHaveBeenCalledWith(expect.stringMatching(/switchbot$/), { recursive: true }); + expect(symlinkSyncMock).toHaveBeenCalledWith( + LINUX_SCOPED_ROOT, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(result).toMatch(/codex-plugin-marketplace$/); + expect(result).not.toContain('@'); + }); + + it('reuses an existing symlink pointing to current packageRoot', () => { + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock + .mockReturnValueOnce(LINUX_SCOPED_ROOT) + .mockReturnValueOnce(LINUX_SCOPED_ROOT); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + expect(result).toMatch(/codex-plugin-marketplace$/); + }); + + it('repairs a stale symlink pointing elsewhere', () => { + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock + .mockReturnValueOnce('/old/path/@switchbot/codex-plugin') + .mockReturnValueOnce(LINUX_SCOPED_ROOT); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(unlinkSyncMock).toHaveBeenCalledWith(expect.stringMatching(/codex-plugin-marketplace$/)); + expect(symlinkSyncMock).toHaveBeenCalledWith( + LINUX_SCOPED_ROOT, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(result).toMatch(/codex-plugin-marketplace$/); + }); + + it('recreates a dangling symlink when realpathSync throws ENOENT (Fix 1)', () => { + lstatSyncMock.mockReturnValue(makeStat(true)); + realpathSyncMock.mockImplementation(() => { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + }); + const result = resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); + expect(unlinkSyncMock).toHaveBeenCalledWith(expect.stringMatching(/codex-plugin-marketplace$/)); + expect(symlinkSyncMock).toHaveBeenCalledWith( + LINUX_SCOPED_ROOT, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(result).toMatch(/codex-plugin-marketplace$/); + }); + + it('throws "not a symlink" without "/junction" suffix on Linux (Fix 5)', () => { + lstatSyncMock.mockReturnValue(makeStat(false)); + let caught: Error | null = null; + try { resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT); } catch (e) { caught = e as Error; } + expect(caught).not.toBeNull(); + expect(caught!.message).toContain('not a symlink'); + expect(caught!.message).not.toContain('symlink/junction'); + }); + + it('throws when alias path is a real directory (not a symlink)', () => { + lstatSyncMock.mockReturnValue(makeStat(false)); + expect(() => resolveMarketplaceSourceRoot(LINUX_SCOPED_ROOT)).toThrow(/not a.*symlink/i); + expect(unlinkSyncMock).not.toHaveBeenCalled(); + expect(symlinkSyncMock).not.toHaveBeenCalled(); + }); + + it('aliases a custom-prefix path that has no node_modules segment', () => { + lstatSyncMock.mockReturnValue(null); + const customPrefixRoot = '/home/user/.local/lib/@switchbot/codex-plugin'; + const result = resolveMarketplaceSourceRoot(customPrefixRoot); + expect(symlinkSyncMock).toHaveBeenCalledWith( + customPrefixRoot, + expect.stringMatching(/codex-plugin-marketplace$/), + 'dir', + ); + expect(result).toMatch(/codex-plugin-marketplace$/); + expect(result).not.toContain('@'); + }); +}); + +describe('runCodexPluginRegistrationGit', () => { + it('returns ok when marketplace add and plugin add both succeed', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(true); + expect(r.exitCode).toBe(0); + }); + + it('returns failure when marketplace add exits non-zero', () => { + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')); + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(false); + expect(r.stderr).toBe('git clone failed'); + expect(r.stage).toBe('marketplace-add'); + }); + + it('returns failure when plugin add exits non-zero', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(1, '', 'plugin add error')); + const r = runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(r.ok).toBe(false); + expect(r.stderr).toBe('plugin add error'); + expect(r.stage).toBe('plugin-add'); + }); + + it('warns to stderr and falls back to 60000 ms when CODEX_MARKETPLACE_ADD_TIMEOUT is "0"', () => { + const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = '0'; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); + spy.mockRestore(); + if (origEnv === undefined) delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + else process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; + }); + + it('does not warn when CODEX_MARKETPLACE_ADD_TIMEOUT is unset', () => { + const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + if (origEnv !== undefined) process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; + }); + + it('warns to stderr when CODEX_MARKETPLACE_ADD_TIMEOUT is empty string', () => { + const origEnv = process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = ''; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + spawnSyncMock.mockReturnValueOnce(makeSpawnResult(1, '', 'fail')); + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('CODEX_MARKETPLACE_ADD_TIMEOUT')); + spy.mockRestore(); + if (origEnv === undefined) delete process.env['CODEX_MARKETPLACE_ADD_TIMEOUT']; + else process.env['CODEX_MARKETPLACE_ADD_TIMEOUT'] = origEnv; + }); + + it('uses default ref "main" when CODEX_GIT_MARKETPLACE_REF is empty string (Fix 2)', () => { + const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; + process.env['CODEX_GIT_MARKETPLACE_REF'] = ''; + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; + expect(refIdx).toBeGreaterThan(-1); + expect(mktCall?.[1][refIdx + 1]).toBe('main'); + if (origEnv === undefined) delete process.env['CODEX_GIT_MARKETPLACE_REF']; + else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; + }); + + it('passes custom CODEX_GIT_MARKETPLACE_REF value as --ref arg', () => { + const origEnv = process.env['CODEX_GIT_MARKETPLACE_REF']; + process.env['CODEX_GIT_MARKETPLACE_REF'] = 'feat/my-branch'; + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + runCodexPluginRegistrationGit('switchbot@codex-plugin'); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const mktCall = calls.find(([cmd, args]) => cmd === 'codex' && args.includes('marketplace')); + const refIdx = mktCall?.[1].indexOf('--ref') ?? -1; + expect(refIdx).toBeGreaterThan(-1); + expect(mktCall?.[1][refIdx + 1]).toBe('feat/my-branch'); + if (origEnv === undefined) delete process.env['CODEX_GIT_MARKETPLACE_REF']; + else process.env['CODEX_GIT_MARKETPLACE_REF'] = origEnv; + }); +}); + +describe('registerCodexPluginAuto', () => { + it('returns git result when Route B succeeds', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (git) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + expect(r.packageRoot).toBeNull(); + }); + + it('falls back to local npm path when Route B fails', () => { + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + expect(r.packageRoot).toMatch(/codex-plugin/); + }); + + it('installs on demand and retries Route A when Route B and initial Route A both fail', () => { + existsSyncMock.mockReturnValue(true); + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g (retry) + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + expect(r.packageRoot).toMatch(/codex-plugin/); + }); + + it('returns failure when Route B fails, Route A fails, and on-demand install also fails', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(1, '', 'EACCES')); // npm install -g: fails + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/git clone failed/); + expect(r.error).toMatch(/npm root error/); + expect(r.error).toMatch(/EACCES/); + }); + + it('returns failure when on-demand install succeeds but Route A retry still fails', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // marketplace add (git) — fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // npm root -g fails (Route A) + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list -g: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: package found + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/git clone failed/); + expect(r.error).toMatch(/npm root error 2/); + }); + + it('skips npm install when npm list output has non-JSON warning prefix (Windows behavior)', () => { + const npmListWithWarnings = + 'npm warn config optional\nnpm warn config another\n' + + '{"dependencies":{"@switchbot/codex-plugin":{"version":"1.0.0"}}}'; + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(0, npmListWithWarnings, '')) // npm list → found + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + // npm install -g must NOT have been called + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); + expect(installCall).toBeUndefined(); + expect(r.ok).toBe(true); + }); + it('skips npm install when npm list exits non-zero but JSON output shows package is installed', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(1, installedJson, 'peer-dep warning')) // npm list exits 1 but JSON shows package + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); + expect(installCall).toBeUndefined(); + expect(r.ok).toBe(true); + }); + + it('returns npm-prefix-mismatch error when post-install npm list still shows package absent', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce(makeSpawnResult(1, '{}', 'peer-dep-warning')); // post-install npm list: still absent + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/npm prefix mismatch/i); + }); + + it('error says "already present" (not "installed") when package existed before and Route A retry fails (Fix 3)', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // npm list: ALREADY installed (no install ran) + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error 2')); // npm root -g fails (retry) + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).not.toMatch(/installed @switchbot/i); + expect(r.error).toMatch(/already present|was present/i); + }); + + it('returns error when post-install verify spawnSync times out (status null)', () => { + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g: succeeds + .mockReturnValueOnce({ status: null, stdout: null, stderr: '', signal: 'SIGTERM' }); // verify times out + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/timed out/i); + }); + + it('falls through to npm install when initial npm list stdout is invalid JSON', () => { + const installedJson = JSON.stringify({ dependencies: { '@switchbot/codex-plugin': { version: '1.0.0' } } }); + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(0, 'npm warn something\nnot-json', '')) // npm list: invalid JSON → fall through + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, installedJson, '')) // post-install npm list: ok + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + const calls = spawnSyncMock.mock.calls as [string, string[]][]; + const installCall = calls.find(([cmd, args]) => cmd === 'npm' && args.includes('install')); + expect(installCall).toBeDefined(); + expect(r.ok).toBe(true); + }); + + it('proceeds to Route A retry when post-install verify stdout is unparseable (inconclusive)', () => { + existsSyncMock.mockReturnValue(true); + spawnSyncMock + .mockReturnValueOnce(makeSpawnResult(1, '', 'git clone failed')) // Route B fails + .mockReturnValueOnce(makeSpawnResult(1, '', 'npm root error')) // Route A: npm root -g fails + .mockReturnValueOnce(makeSpawnResult(1, '{}', '')) // npm list: not installed + .mockReturnValueOnce(makeSpawnResult(0, '', '')) // npm install -g succeeds + .mockReturnValueOnce(makeSpawnResult(0, 'garbage\n', '')) // post-install verify: invalid JSON + .mockReturnValueOnce(makeSpawnResult(0, '/usr/local/lib/node_modules\n', '')) // npm root -g retry + .mockReturnValueOnce(makeSpawnResult(0, '')) // marketplace add (local) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (current id) + .mockReturnValueOnce(makeSpawnResult(0, '')) // plugin remove (legacy id) + .mockReturnValueOnce(makeSpawnResult(0, '')); // plugin add + const r = registerCodexPluginAuto(); + expect(r.ok).toBe(true); + }); +}); + describe('stepRegisterCodexPlugin', () => { function makeCtx(overrides: Partial = {}): InstallContext { return { @@ -320,9 +737,9 @@ describe('stepRegisterCodexPlugin', () => { it('sets codexPluginRegistered and codexPluginIdentifier on success', async () => { spawnSyncMock - .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) // npm root -g - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add - .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (pre-clean) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // marketplace add (git) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (current id) + .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }) // plugin remove (legacy id) .mockReturnValueOnce({ status: 0, stdout: '', stderr: '' }); // plugin add const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); @@ -331,17 +748,12 @@ describe('stepRegisterCodexPlugin', () => { expect(ctx.codexPluginIdentifier).toBe('switchbot@codex-plugin'); }); - it('throws when npm root -g fails', async () => { - spawnSyncMock.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm error' }); - const step = stepRegisterCodexPlugin(); - const ctx = makeCtx(); - await expect(step.execute(ctx)).rejects.toThrow('npm root -g failed'); - }); - it('throws when runCodexPluginRegistration fails', async () => { spawnSyncMock - .mockReturnValueOnce({ status: 0, stdout: '/usr/local/lib/node_modules\n', stderr: '' }) - .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }); + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'marketplace error' }) // marketplace add (git) — Route B fails + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'npm root error' }) // npm root -g — Route A fails + .mockReturnValueOnce({ status: 1, stdout: '{}', stderr: '' }) // npm list -g: not installed + .mockReturnValueOnce({ status: 1, stdout: '', stderr: 'EACCES' }); // npm install -g: fails (on-demand) const step = stepRegisterCodexPlugin(); const ctx = makeCtx(); await expect(step.execute(ctx)).rejects.toThrow('Codex plugin registration failed'); diff --git a/tests/install/preflight.test.ts b/tests/install/preflight.test.ts index 568f711..9a718ad 100644 --- a/tests/install/preflight.test.ts +++ b/tests/install/preflight.test.ts @@ -170,4 +170,23 @@ describe('runPreflight', () => { existsSpy.mockRestore(); } }); + + it('codex agent: missing global npm package is warn (not fail) — Route B does not need it', async () => { + // npm list -g will return non-zero / empty in the test environment; that used + // to be a hard 'fail' blocking the entire install. With Route B it should + // only be a warning so registerCodexPluginAuto() gets a chance to run. + const res = await runPreflight({ agent: 'codex' }); + const npmCheck = res.checks.find((c) => c.name === 'codex-plugin-npm'); + if (npmCheck) { + // When the npm package is absent the check must be at most 'warn'. + expect(npmCheck.status).not.toBe('fail'); + } + // Overall preflight must not be blocked by this check alone. + const nonNpmFails = res.checks.filter( + (c) => c.status === 'fail' && c.name !== 'codex-plugin-npm', + ); + if (nonNpmFails.length === 0) { + expect(res.ok).toBe(true); + } + }); });